阅读视图

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

📦 从npm到yarn到pnpm的演进之路 - 包管理器实现原理深度解析

🎯 学习目标:深入理解npm、yarn、pnpm三大包管理器的演进历史、实现原理和核心差异

📊 难度等级:中级-高级
🏷️ 技术标签#npm #yarn #pnpm #包管理器 #Node.js
⏱️ 阅读时间:约15分钟


🌟 引言

在前端开发的世界里,包管理器就像是项目的"生命线",它决定了我们如何管理依赖、构建项目、发布代码。从最初的npm到后来的yarn,再到现在备受推崇的pnpm,每一次演进都解决了前一代的痛点。

你是否遇到过这样的困扰:

  • npm安装慢如蜗牛:每次npm install都要等半天,喝杯咖啡回来还在下载
  • 依赖版本冲突:明明本地能跑,到了CI就报错,版本锁定文件各种冲突
  • 磁盘空间爆炸:每个项目都有自己的node_modules,硬盘被重复的包塞满
  • 幽灵依赖问题:代码里用了某个包,但package.json里没有,本地能跑线上就崩

今天我们从实现原理的角度深度解析npm、yarn、pnpm的演进历程,让你彻底理解包管理器的核心机制!


💡 包管理器演进史详解

1. npm时代:开创者的荣耀与痛点

🔍 npm的核心实现原理

npm(Node Package Manager)作为Node.js的官方包管理器,采用了最直观的嵌套依赖结构

# npm v2及之前的目录结构
node_modules/
├── package-a/
│   ├── index.js
│   └── node_modules/
│       └── lodash@3.0.0/
└── package-b/
    ├── index.js
    └── node_modules/
        └── lodash@4.0.0/

❌ npm早期的核心问题

1. 依赖地狱(Dependency Hell)

/**
 * npm v2的嵌套依赖问题演示
 * @description 展示深层嵌套导致的路径过长问题
 */
const demonstrateNpmV2Issues = () => {
  // Windows系统路径长度限制:260字符
  const examplePath = `
    node_modules/package-a/node_modules/package-b/node_modules/package-c/
    node_modules/package-d/node_modules/package-e/node_modules/lodash/index.js
  `;
  
  console.log('路径长度:', examplePath.length); // 超过260字符限制
  
  // 导致的问题:
  // 1. Windows无法创建文件
  // 2. 删除node_modules失败
  // 3. 构建工具无法访问文件
};

2. 重复依赖占用空间

/**
 * 计算npm v2重复依赖占用的磁盘空间
 * @description 同一个包的不同版本被重复安装
 */
const calculateDuplicateSpace = () => {
  const duplicatePackages = {
    'lodash@3.0.0': '2.5MB',
    'lodash@4.0.0': '2.8MB', 
    'lodash@4.17.21': '3.2MB'
  };
  
  // 在一个项目中,lodash可能被安装多次
  const projectStructure = {
    'package-a': 'lodash@3.0.0',
    'package-b': 'lodash@4.0.0',
    'package-c': 'lodash@4.17.21'
  };
  
  console.log('重复安装导致的空间浪费:', '8.5MB for just lodash');
};

✅ npm v3的扁平化改进

npm v3引入了**扁平化安装(Flat Installation)**机制:

/**
 * npm v3扁平化算法实现原理
 * @description 将依赖提升到顶层,减少重复
 */
const npmV3FlattenAlgorithm = (dependencies) => {
  const flattenedStructure = new Map();
  const conflicts = new Map();
  
  /**
   * 扁平化依赖树
   * @param {Object} deps - 依赖对象
   * @param {string} currentPath - 当前路径
   */
  const flattenDeps = (deps, currentPath = '') => {
    Object.entries(deps).forEach(([name, version]) => {
      const key = name;
      
      if (!flattenedStructure.has(key)) {
        // 首次遇到,提升到顶层
        flattenedStructure.set(key, {
          version,
          path: 'node_modules/' + name
        });
      } else {
        // 版本冲突,保持嵌套
        const existing = flattenedStructure.get(key);
        if (existing.version !== version) {
          conflicts.set(`${currentPath}/${name}`, version);
        }
      }
    });
  };
  
  return { flattenedStructure, conflicts };
};

// 使用示例
const projectDeps = {
  'react': '^17.0.0',
  'lodash': '^4.17.21',
  'axios': '^0.24.0'
};

const result = npmV3FlattenAlgorithm(projectDeps);
console.log('扁平化结果:', result);

💡 npm的核心机制

1. package-lock.json的锁定机制

/**
 * package-lock.json生成算法
 * @description 确保依赖版本的一致性
 */
const generatePackageLock = (packageJson) => {
  const lockStructure = {
    name: packageJson.name,
    version: packageJson.version,
    lockfileVersion: 2,
    requires: true,
    packages: {},
    dependencies: {}
  };
  
  /**
   * 解析依赖版本
   * @param {string} versionRange - 版本范围(如^1.0.0)
   * @returns {string} 具体版本号
   */
  const resolveVersion = (versionRange) => {
    // 模拟npm registry查询
    if (versionRange.startsWith('^')) {
      return versionRange.slice(1); // 简化处理
    }
    return versionRange;
  };
  
  // 递归解析所有依赖
  const resolveDependencies = (deps, path = '') => {
    Object.entries(deps || {}).forEach(([name, versionRange]) => {
      const resolvedVersion = resolveVersion(versionRange);
      const packagePath = path ? `${path}/node_modules/${name}` : `node_modules/${name}`;
      
      lockStructure.packages[packagePath] = {
        version: resolvedVersion,
        resolved: `https://registry.npmjs.org/${name}/-/${name}-${resolvedVersion}.tgz`,
        integrity: `sha512-...`, // 完整性校验
        dependencies: {}
      };
    });
  };
  
  resolveDependencies(packageJson.dependencies);
  return lockStructure;
};

2. Yarn时代:Facebook的革命性改进

🔍 Yarn的核心创新

Yarn(Yet Another Resource Negotiator)由Facebook开发,主要解决npm的性能和确定性问题:

1. 并行下载机制

/**
 * Yarn并行下载实现原理
 * @description 同时下载多个包,提升安装速度
 */
class YarnParallelDownloader {
  constructor(maxConcurrency = 10) {
    this.maxConcurrency = maxConcurrency;
    this.downloadQueue = [];
    this.activeDownloads = new Set();
  }
  
  /**
   * 并行下载包
   * @param {Array} packages - 待下载的包列表
   * @returns {Promise} 下载完成的Promise
   */
  async downloadPackages(packages) {
    const downloadPromises = packages.map(pkg => this.queueDownload(pkg));
    return Promise.all(downloadPromises);
  }
  
  /**
   * 队列化下载任务
   * @param {Object} pkg - 包信息
   * @returns {Promise} 单个包的下载Promise
   */
  async queueDownload(pkg) {
    return new Promise((resolve, reject) => {
      const downloadTask = async () => {
        try {
          this.activeDownloads.add(pkg.name);
          
          // 模拟下载过程
          const downloadResult = await this.downloadSinglePackage(pkg);
          
          this.activeDownloads.delete(pkg.name);
          resolve(downloadResult);
          
          // 处理队列中的下一个任务
          this.processQueue();
        } catch (error) {
          this.activeDownloads.delete(pkg.name);
          reject(error);
        }
      };
      
      if (this.activeDownloads.size < this.maxConcurrency) {
        downloadTask();
      } else {
        this.downloadQueue.push(downloadTask);
      }
    });
  }
  
  /**
   * 处理下载队列
   */
  processQueue() {
    if (this.downloadQueue.length > 0 && this.activeDownloads.size < this.maxConcurrency) {
      const nextTask = this.downloadQueue.shift();
      nextTask();
    }
  }
  
  /**
   * 下载单个包
   * @param {Object} pkg - 包信息
   * @returns {Promise} 下载结果
   */
  async downloadSinglePackage(pkg) {
    // 实际的下载逻辑
    console.log(`正在下载: ${pkg.name}@${pkg.version}`);
    
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, Math.random() * 1000));
    
    return {
      name: pkg.name,
      version: pkg.version,
      downloadTime: Date.now()
    };
  }
}

// 使用示例
const downloader = new YarnParallelDownloader(5);
const packages = [
  { name: 'react', version: '17.0.2' },
  { name: 'lodash', version: '4.17.21' },
  { name: 'axios', version: '0.24.0' }
];

downloader.downloadPackages(packages).then(results => {
  console.log('所有包下载完成:', results);
});

2. 确定性安装(Deterministic Installation)

/**
 * Yarn确定性安装算法
 * @description 确保在不同环境中安装结果一致
 */
class YarnDeterministicInstaller {
  constructor() {
    this.lockfile = new Map();
    this.resolutionMap = new Map();
  }
  
  /**
   * 生成确定性的依赖解析
   * @param {Object} packageJson - package.json内容
   * @returns {Object} 解析结果
   */
  generateDeterministicResolution(packageJson) {
    const resolution = {
      dependencies: new Map(),
      resolutions: new Map()
    };
    
    /**
     * 解析依赖版本
     * @param {string} name - 包名
     * @param {string} versionRange - 版本范围
     * @returns {string} 确定的版本号
     */
    const resolveVersion = (name, versionRange) => {
      const cacheKey = `${name}@${versionRange}`;
      
      if (this.resolutionMap.has(cacheKey)) {
        return this.resolutionMap.get(cacheKey);
      }
      
      // 模拟版本解析逻辑
      let resolvedVersion;
      if (versionRange.startsWith('^')) {
        resolvedVersion = this.findHighestCompatibleVersion(name, versionRange);
      } else if (versionRange.startsWith('~')) {
        resolvedVersion = this.findPatchVersion(name, versionRange);
      } else {
        resolvedVersion = versionRange;
      }
      
      this.resolutionMap.set(cacheKey, resolvedVersion);
      return resolvedVersion;
    };
    
    // 按字母顺序处理依赖,确保一致性
    const sortedDeps = Object.keys(packageJson.dependencies || {}).sort();
    
    sortedDeps.forEach(name => {
      const versionRange = packageJson.dependencies[name];
      const resolvedVersion = resolveVersion(name, versionRange);
      
      resolution.dependencies.set(name, {
        version: resolvedVersion,
        resolved: `https://registry.yarnpkg.com/${name}/-/${name}-${resolvedVersion}.tgz`,
        integrity: this.calculateIntegrity(name, resolvedVersion)
      });
    });
    
    return resolution;
  }
  
  /**
   * 查找最高兼容版本
   * @param {string} name - 包名
   * @param {string} versionRange - 版本范围
   * @returns {string} 版本号
   */
  findHighestCompatibleVersion(name, versionRange) {
    // 模拟从registry获取版本列表
    const availableVersions = ['1.0.0', '1.0.1', '1.1.0', '2.0.0'];
    const baseVersion = versionRange.slice(1); // 移除^符号
    
    return availableVersions
      .filter(v => this.isCompatible(v, baseVersion))
      .sort(this.compareVersions)
      .pop();
  }
  
  /**
   * 版本兼容性检查
   * @param {string} version - 版本号
   * @param {string} baseVersion - 基础版本
   * @returns {boolean} 是否兼容
   */
  isCompatible(version, baseVersion) {
    const [vMajor, vMinor, vPatch] = version.split('.').map(Number);
    const [bMajor, bMinor, bPatch] = baseVersion.split('.').map(Number);
    
    return vMajor === bMajor && (vMinor > bMinor || (vMinor === bMinor && vPatch >= bPatch));
  }
  
  /**
   * 版本比较
   * @param {string} a - 版本a
   * @param {string} b - 版本b
   * @returns {number} 比较结果
   */
  compareVersions(a, b) {
    const aParts = a.split('.').map(Number);
    const bParts = b.split('.').map(Number);
    
    for (let i = 0; i < 3; i++) {
      if (aParts[i] !== bParts[i]) {
        return aParts[i] - bParts[i];
      }
    }
    return 0;
  }
  
  /**
   * 计算包的完整性校验
   * @param {string} name - 包名
   * @param {string} version - 版本号
   * @returns {string} 完整性哈希
   */
  calculateIntegrity(name, version) {
    // 模拟SHA-512计算
    return `sha512-${Buffer.from(`${name}@${version}`).toString('base64')}`;
  }
}

3. Workspaces工作区支持

/**
 * Yarn Workspaces实现原理
 * @description 支持monorepo项目管理
 */
class YarnWorkspaces {
  constructor(rootPath) {
    this.rootPath = rootPath;
    this.workspaces = new Map();
    this.hoistedDependencies = new Map();
  }
  
  /**
   * 解析工作区配置
   * @param {Object} rootPackageJson - 根目录package.json
   * @returns {Array} 工作区列表
   */
  parseWorkspaces(rootPackageJson) {
    const workspacePatterns = rootPackageJson.workspaces || [];
    const workspaceList = [];
    
    workspacePatterns.forEach(pattern => {
      // 模拟glob匹配
      if (pattern.includes('*')) {
        // packages/* -> packages/app1, packages/app2
        const matchedPaths = this.globMatch(pattern);
        workspaceList.push(...matchedPaths);
      } else {
        workspaceList.push(pattern);
      }
    });
    
    return workspaceList;
  }
  
  /**
   * 依赖提升算法
   * @param {Array} workspaces - 工作区列表
   * @returns {Object} 提升结果
   */
  hoistDependencies(workspaces) {
    const allDependencies = new Map();
    const conflicts = new Map();
    
    // 收集所有工作区的依赖
    workspaces.forEach(workspace => {
      const packageJson = this.readPackageJson(workspace);
      const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
      
      Object.entries(deps).forEach(([name, version]) => {
        if (!allDependencies.has(name)) {
          allDependencies.set(name, { version, workspaces: [workspace] });
        } else {
          const existing = allDependencies.get(name);
          if (existing.version !== version) {
            // 版本冲突
            if (!conflicts.has(name)) {
              conflicts.set(name, [existing, { version, workspaces: [workspace] }]);
            } else {
              conflicts.get(name).push({ version, workspaces: [workspace] });
            }
          } else {
            existing.workspaces.push(workspace);
          }
        }
      });
    });
    
    return { hoisted: allDependencies, conflicts };
  }
  
  /**
   * 创建符号链接
   * @param {string} source - 源路径
   * @param {string} target - 目标路径
   */
  createSymlink(source, target) {
    // 在实际实现中,这里会调用fs.symlink
    console.log(`创建符号链接: ${source} -> ${target}`);
  }
  
  /**
   * 模拟glob匹配
   * @param {string} pattern - 匹配模式
   * @returns {Array} 匹配的路径
   */
  globMatch(pattern) {
    // 简化的glob实现
    if (pattern === 'packages/*') {
      return ['packages/app1', 'packages/app2', 'packages/shared'];
    }
    return [];
  }
  
  /**
   * 读取package.json
   * @param {string} workspacePath - 工作区路径
   * @returns {Object} package.json内容
   */
  readPackageJson(workspacePath) {
    // 模拟读取文件
    return {
      name: `@workspace/${workspacePath.split('/').pop()}`,
      dependencies: {
        'lodash': '^4.17.21',
        'react': '^17.0.2'
      },
      devDependencies: {
        'jest': '^27.0.0'
      }
    };
  }
}

// 使用示例
const workspaces = new YarnWorkspaces('/project/root');
const rootPackage = {
  workspaces: ['packages/*', 'tools/*']
};

const workspaceList = workspaces.parseWorkspaces(rootPackage);
const hoistResult = workspaces.hoistDependencies(workspaceList);
console.log('依赖提升结果:', hoistResult);

3. pnpm时代:革命性的存储机制

🔍 pnpm的核心创新:内容寻址存储

pnpm(Performant npm)采用了完全不同的存储策略,通过硬链接符号链接实现真正的去重:

1. 内容寻址存储(Content-Addressable Storage)

/**
 * pnpm内容寻址存储实现原理
 * @description 基于文件内容哈希的存储系统
 */
class PnpmContentAddressableStore {
  constructor(storePath = '~/.pnpm-store') {
    this.storePath = storePath;
    this.contentMap = new Map(); // 内容哈希 -> 文件路径
    this.packageMap = new Map(); // 包名@版本 -> 内容哈希
  }
  
  /**
   * 计算文件内容哈希
   * @param {Buffer} content - 文件内容
   * @returns {string} SHA-256哈希值
   */
  calculateContentHash(content) {
    const crypto = require('crypto');
    return crypto.createHash('sha256').update(content).digest('hex');
  }
  
  /**
   * 存储包文件
   * @param {string} packageName - 包名
   * @param {string} version - 版本号
   * @param {Buffer} tarballContent - tar包内容
   * @returns {string} 存储路径
   */
  storePackage(packageName, version, tarballContent) {
    const contentHash = this.calculateContentHash(tarballContent);
    const packageKey = `${packageName}@${version}`;
    
    // 检查是否已存储
    if (this.contentMap.has(contentHash)) {
      console.log(`包 ${packageKey} 已存在,复用存储`);
      this.packageMap.set(packageKey, contentHash);
      return this.contentMap.get(contentHash);
    }
    
    // 存储到内容寻址路径
    const storePath = `${this.storePath}/v3/files/${contentHash.slice(0, 2)}/${contentHash}`;
    
    // 模拟文件写入
    this.writeToStore(storePath, tarballContent);
    
    // 更新映射
    this.contentMap.set(contentHash, storePath);
    this.packageMap.set(packageKey, contentHash);
    
    console.log(`包 ${packageKey} 存储到: ${storePath}`);
    return storePath;
  }
  
  /**
   * 获取包的存储路径
   * @param {string} packageName - 包名
   * @param {string} version - 版本号
   * @returns {string|null} 存储路径
   */
  getPackagePath(packageName, version) {
    const packageKey = `${packageName}@${version}`;
    const contentHash = this.packageMap.get(packageKey);
    
    if (contentHash && this.contentMap.has(contentHash)) {
      return this.contentMap.get(contentHash);
    }
    
    return null;
  }
  
  /**
   * 写入存储
   * @param {string} path - 存储路径
   * @param {Buffer} content - 内容
   */
  writeToStore(path, content) {
    // 实际实现中会创建目录并写入文件
    console.log(`写入存储: ${path}, 大小: ${content.length} bytes`);
  }
  
  /**
   * 获取存储统计信息
   * @returns {Object} 统计信息
   */
  getStorageStats() {
    return {
      totalPackages: this.packageMap.size,
      uniqueContents: this.contentMap.size,
      deduplicationRatio: this.packageMap.size / this.contentMap.size
    };
  }
}

// 使用示例
const store = new PnpmContentAddressableStore();

// 模拟存储相同内容的不同包
const lodashContent = Buffer.from('lodash-4.17.21-content');
store.storePackage('lodash', '4.17.21', lodashContent);
store.storePackage('@types/lodash', '4.14.175', lodashContent); // 相同内容

console.log('存储统计:', store.getStorageStats());

2. 硬链接和符号链接机制

/**
 * pnpm链接机制实现
 * @description 通过硬链接和符号链接构建node_modules
 */
class PnpmLinkManager {
  constructor(projectPath, storePath) {
    this.projectPath = projectPath;
    this.storePath = storePath;
    this.virtualStore = `${projectPath}/node_modules/.pnpm`;
  }
  
  /**
   * 创建虚拟存储结构
   * @param {Object} lockfile - pnpm-lock.yaml内容
   * @returns {Object} 虚拟存储映射
   */
  createVirtualStore(lockfile) {
    const virtualStoreMap = new Map();
    
    Object.entries(lockfile.packages || {}).forEach(([packageId, packageInfo]) => {
      const virtualPath = this.createVirtualPath(packageId);
      virtualStoreMap.set(packageId, virtualPath);
      
      // 创建硬链接到全局存储
      this.createHardLink(packageInfo.storePath, virtualPath);
      
      // 处理依赖的符号链接
      this.createDependencySymlinks(packageId, packageInfo.dependencies, virtualStoreMap);
    });
    
    return virtualStoreMap;
  }
  
  /**
   * 创建虚拟路径
   * @param {string} packageId - 包标识符
   * @returns {string} 虚拟路径
   */
  createVirtualPath(packageId) {
    // 将包ID转换为文件系统安全的路径
    const safeName = packageId.replace(/[@\/]/g, '+');
    return `${this.virtualStore}/${safeName}/node_modules`;
  }
  
  /**
   * 创建硬链接
   * @param {string} sourcePath - 源路径(全局存储)
   * @param {string} targetPath - 目标路径(虚拟存储)
   */
  createHardLink(sourcePath, targetPath) {
    // 实际实现中会调用fs.link
    console.log(`创建硬链接: ${sourcePath} -> ${targetPath}`);
  }
  
  /**
   * 创建依赖的符号链接
   * @param {string} packageId - 当前包ID
   * @param {Object} dependencies - 依赖列表
   * @param {Map} virtualStoreMap - 虚拟存储映射
   */
  createDependencySymlinks(packageId, dependencies, virtualStoreMap) {
    if (!dependencies) return;
    
    const packageVirtualPath = virtualStoreMap.get(packageId);
    
    Object.entries(dependencies).forEach(([depName, depVersion]) => {
      const depId = `${depName}@${depVersion}`;
      const depVirtualPath = virtualStoreMap.get(depId);
      
      if (depVirtualPath) {
        const symlinkPath = `${packageVirtualPath}/${depName}`;
        this.createSymlink(depVirtualPath, symlinkPath);
      }
    });
  }
  
  /**
   * 创建符号链接
   * @param {string} targetPath - 目标路径
   * @param {string} linkPath - 链接路径
   */
  createSymlink(targetPath, linkPath) {
    // 实际实现中会调用fs.symlink
    console.log(`创建符号链接: ${linkPath} -> ${targetPath}`);
  }
  
  /**
   * 创建顶层依赖的符号链接
   * @param {Object} dependencies - 顶层依赖
   * @param {Map} virtualStoreMap - 虚拟存储映射
   */
  createTopLevelSymlinks(dependencies, virtualStoreMap) {
    Object.entries(dependencies).forEach(([depName, depVersion]) => {
      const depId = `${depName}@${depVersion}`;
      const depVirtualPath = virtualStoreMap.get(depId);
      
      if (depVirtualPath) {
        const topLevelPath = `${this.projectPath}/node_modules/${depName}`;
        this.createSymlink(depVirtualPath, topLevelPath);
      }
    });
  }
  
  /**
   * 计算磁盘使用情况
   * @param {Map} virtualStoreMap - 虚拟存储映射
   * @returns {Object} 磁盘使用统计
   */
  calculateDiskUsage(virtualStoreMap) {
    const stats = {
      hardLinks: 0,
      symlinks: 0,
      totalSize: 0,
      savedSpace: 0
    };
    
    virtualStoreMap.forEach((virtualPath, packageId) => {
      stats.hardLinks++;
      // 硬链接不占用额外空间
      stats.savedSpace += this.getPackageSize(packageId);
    });
    
    return stats;
  }
  
  /**
   * 获取包大小
   * @param {string} packageId - 包ID
   * @returns {number} 包大小(字节)
   */
  getPackageSize(packageId) {
    // 模拟获取包大小
    return Math.random() * 1024 * 1024; // 随机大小,实际中从存储获取
  }
}

// 使用示例
const linkManager = new PnpmLinkManager('/project', '~/.pnpm-store');

const mockLockfile = {
  packages: {
    'lodash@4.17.21': {
      storePath: '~/.pnpm-store/v3/files/ab/cd1234...',
      dependencies: {}
    },
    'react@17.0.2': {
      storePath: '~/.pnpm-store/v3/files/ef/gh5678...',
      dependencies: {
        'object-assign': '4.1.1'
      }
    }
  }
};

const virtualStore = linkManager.createVirtualStore(mockLockfile);
console.log('虚拟存储创建完成:', virtualStore);

3. 严格的依赖隔离

/**
 * pnpm依赖隔离机制
 * @description 防止幽灵依赖,确保依赖访问的严格性
 */
class PnpmDependencyIsolation {
  constructor() {
    this.dependencyGraph = new Map();
    this.accessibleDependencies = new Map();
  }
  
  /**
   * 构建依赖图
   * @param {Object} lockfile - pnpm-lock.yaml内容
   * @returns {Map} 依赖图
   */
  buildDependencyGraph(lockfile) {
    Object.entries(lockfile.packages || {}).forEach(([packageId, packageInfo]) => {
      const dependencies = new Set();
      
      // 直接依赖
      Object.keys(packageInfo.dependencies || {}).forEach(dep => {
        dependencies.add(dep);
      });
      
      this.dependencyGraph.set(packageId, dependencies);
    });
    
    return this.dependencyGraph;
  }
  
  /**
   * 计算可访问的依赖
   * @param {string} packageId - 包ID
   * @param {Set} visited - 已访问的包(防止循环依赖)
   * @returns {Set} 可访问的依赖集合
   */
  calculateAccessibleDependencies(packageId, visited = new Set()) {
    if (visited.has(packageId)) {
      return new Set(); // 防止循环依赖
    }
    
    if (this.accessibleDependencies.has(packageId)) {
      return this.accessibleDependencies.get(packageId);
    }
    
    visited.add(packageId);
    const accessible = new Set();
    const directDeps = this.dependencyGraph.get(packageId) || new Set();
    
    // 添加直接依赖
    directDeps.forEach(dep => {
      accessible.add(dep);
      
      // 递归添加传递依赖
      const transitiveDeps = this.calculateAccessibleDependencies(dep, new Set(visited));
      transitiveDeps.forEach(transitiveDep => accessible.add(transitiveDep));
    });
    
    this.accessibleDependencies.set(packageId, accessible);
    return accessible;
  }
  
  /**
   * 验证依赖访问
   * @param {string} fromPackage - 访问者包
   * @param {string} targetPackage - 目标包
   * @returns {boolean} 是否允许访问
   */
  validateDependencyAccess(fromPackage, targetPackage) {
    const accessible = this.calculateAccessibleDependencies(fromPackage);
    return accessible.has(targetPackage);
  }
  
  /**
   * 检测幽灵依赖
   * @param {Object} packageJson - package.json内容
   * @param {Array} actualImports - 实际导入的包列表
   * @returns {Array} 幽灵依赖列表
   */
  detectPhantomDependencies(packageJson, actualImports) {
    const declaredDeps = new Set([
      ...Object.keys(packageJson.dependencies || {}),
      ...Object.keys(packageJson.devDependencies || {})
    ]);
    
    const phantomDeps = actualImports.filter(importedPkg => {
      return !declaredDeps.has(importedPkg) && 
             !this.isBuiltinModule(importedPkg);
    });
    
    return phantomDeps;
  }
  
  /**
   * 检查是否为内置模块
   * @param {string} moduleName - 模块名
   * @returns {boolean} 是否为内置模块
   */
  isBuiltinModule(moduleName) {
    const builtinModules = [
      'fs', 'path', 'http', 'https', 'crypto', 'os', 'util', 'events'
    ];
    return builtinModules.includes(moduleName);
  }
  
  /**
   * 生成依赖访问报告
   * @param {string} packageId - 包ID
   * @returns {Object} 访问报告
   */
  generateAccessReport(packageId) {
    const accessible = this.calculateAccessibleDependencies(packageId);
    const direct = this.dependencyGraph.get(packageId) || new Set();
    
    return {
      packageId,
      directDependencies: Array.from(direct),
      accessibleDependencies: Array.from(accessible),
      dependencyCount: {
        direct: direct.size,
        total: accessible.size
      }
    };
  }
}

// 使用示例
const isolation = new PnpmDependencyIsolation();

const mockLockfile = {
  packages: {
    'my-app@1.0.0': {
      dependencies: { 'lodash': '4.17.21', 'react': '17.0.2' }
    },
    'lodash@4.17.21': {
      dependencies: {}
    },
    'react@17.0.2': {
      dependencies: { 'object-assign': '4.1.1' }
    },
    'object-assign@4.1.1': {
      dependencies: {}
    }
  }
};

isolation.buildDependencyGraph(mockLockfile);

// 检查my-app是否可以访问object-assign(应该不能,因为不是直接依赖)
const canAccess = isolation.validateDependencyAccess('my-app@1.0.0', 'object-assign');
console.log('my-app可以访问object-assign:', canAccess);

// 生成访问报告
const report = isolation.generateAccessReport('my-app@1.0.0');
console.log('依赖访问报告:', report);

📊 三大包管理器对比总结

特性 npm yarn pnpm
安装速度 较慢(串行下载) 快(并行下载) 最快(硬链接复用)
磁盘占用 高(重复存储) 中等(部分去重) 最低(全局去重)
依赖一致性 package-lock.json yarn.lock pnpm-lock.yaml
幽灵依赖 存在 存在 完全避免
Monorepo支持 基础支持 优秀(Workspaces) 优秀(内置支持)
存储机制 嵌套/扁平化 扁平化 内容寻址存储
网络效率 一般 好(缓存优化) 最好(增量下载)

🎯 实战应用建议

最佳实践选择

  1. 新项目推荐pnpm

    • 磁盘空间节省70%以上
    • 安装速度提升2-3倍
    • 严格的依赖管理避免隐患
  2. 大型Monorepo项目

    • pnpm的workspace功能最强大
    • 依赖提升和隔离机制完善
    • 支持复杂的依赖关系管理
  3. CI/CD环境优化

    • 使用pnpm可显著减少构建时间
    • 缓存机制更高效
    • 依赖安装更稳定

迁移策略

/**
 * 包管理器迁移工具
 * @description 帮助项目从npm/yarn迁移到pnpm
 */
class PackageManagerMigrator {
  /**
   * 从npm迁移到pnpm
   * @param {string} projectPath - 项目路径
   */
  async migrateFromNpm(projectPath) {
    console.log('开始从npm迁移到pnpm...');
    
    // 1. 删除node_modules和package-lock.json
    await this.cleanup(projectPath, ['node_modules', 'package-lock.json']);
    
    // 2. 安装pnpm
    await this.installPnpm();
    
    // 3. 运行pnpm install
    await this.runCommand('pnpm install', projectPath);
    
    // 4. 验证安装结果
    await this.validateInstallation(projectPath);
    
    console.log('迁移完成!');
  }
  
  /**
   * 清理旧文件
   * @param {string} projectPath - 项目路径
   * @param {Array} filesToRemove - 要删除的文件/目录
   */
  async cleanup(projectPath, filesToRemove) {
    filesToRemove.forEach(file => {
      console.log(`删除 ${file}...`);
      // 实际实现中会调用fs.rm或rimraf
    });
  }
  
  /**
   * 安装pnpm
   */
  async installPnpm() {
    console.log('安装pnpm...');
    // npm install -g pnpm
  }
  
  /**
   * 运行命令
   * @param {string} command - 命令
   * @param {string} cwd - 工作目录
   */
  async runCommand(command, cwd) {
    console.log(`运行命令: ${command}`);
    // 实际实现中会调用child_process.exec
  }
  
  /**
   * 验证安装结果
   * @param {string} projectPath - 项目路径
   */
  async validateInstallation(projectPath) {
    console.log('验证安装结果...');
    // 检查pnpm-lock.yaml是否生成
    // 检查node_modules结构是否正确
    // 运行测试确保功能正常
  }
}

💡 总结

从npm到yarn再到pnpm的演进历程,体现了前端工程化的不断进步:

  1. npm奠定基础:建立了包管理的基本概念和生态系统
  2. yarn优化体验:解决了性能和一致性问题,引入了现代化的特性
  3. pnpm革新存储:通过创新的存储机制,实现了真正的高效和安全

核心技术演进

  • 存储方式:嵌套 → 扁平化 → 内容寻址存储
  • 安装策略:串行 → 并行 → 增量复用
  • 依赖管理:宽松 → 锁定 → 严格隔离
  • 空间效率:重复存储 → 部分去重 → 全局去重

选择合适的包管理器不仅能提升开发效率,更能避免许多潜在的依赖问题。在现代前端开发中,理解这些工具的实现原理,有助于我们做出更明智的技术决策!


🔗 相关资源


💡 今日收获:深入理解了npm、yarn、pnpm三大包管理器的演进历史和实现原理,掌握了包管理器的核心技术机制。

如果这篇文章对你有帮助,欢迎点赞、收藏和分享!有任何问题也欢迎在评论区讨论。 🚀

single-spa原理解析

内容介绍

本篇文章内容较长,涵盖了single-spa框架的注册(registerApplication),开始(start),加载(loadApps),切换(performAppChanges),以及生命周期,同时也有我对源码一些细节上的问题和答案,同时在源码之间也穿插着我的解释,相信会对大家理解有所帮助。有什么问题可以在评论区交流,谢谢大家!

参考

  1. single-spa官网
  2. 一版通俗易懂的single-spa手写项目地址
  3. single-spa框架源码地址
  1. single-spa框架api概览以及详解

      整个 single-spa 源码可以被分为几个主要部分,如下所示:

    • applications:注册微应用并解析微应用的注册参数

    • start:启动微应用的生命周期函数执行

    • reroute:在微应用需要发生变化时触发

    • loadApps:未调用start时候调用reroute会调用loadApps加载微应用

    • performAppChanges:start函数已经被调用过触发reroute时会调用performAppChanges处理微应用变化

    • navigation:处理导航事件、根据导航变化计算和执行微应用的变化

    • lifecycles:异步执行微应用的生命周期函数以及相应的错误处理

  single-spa 全部状态

// App statuses 12
export const NOT_LOADED = "NOT_LOADED";//single-spa应用注册了,还未加载。
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE";//应用代码正在被拉取。
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED";//应用已经加载,还未初始化。
export const BOOTSTRAPPING = "BOOTSTRAPPING";//初始化中
export const NOT_MOUNTED = "NOT_MOUNTED";//应用已经加载和初始化,还未挂载。
export const MOUNTING = "MOUNTING";//应用正在被挂载,还未结束。
export const MOUNTED = "MOUNTED";//应用目前处于激活状态,已经挂载到DOM元素上。
export const UPDATING = "UPDATING";//更新中
export const UNMOUNTING = "UNMOUNTING";//应用正在被卸载,还未结束。
export const UNLOADING = "UNLOADING";//应用正在被移除,还未结束。
export const LOAD_ERROR = "LOAD_ERROR";//加载错误
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN";//跳过加载

  registerApplication 解析

registerApplication 是 single-spa 对外提供的注册 API,它可以通过对象或者函数两种方式进行调用,在主应用中的使用如下所示:

 // single-spa-config.js
// 引入 single-spa 的 NPM 库包
import { registerApplication, start } from 'single-spa';  
  
// 函数调用方式,需要提供四个参数
registerApplication(  
  // 参数 1:微应用名称标识
  'app2',  
  // 参数 2:微应用加载逻辑 / 微应用对象,必须返回 Promise
  () => import('src/app2/main.js'),  
  // 参数 3:微应用的激活条件
  (location) => location.pathname.startsWith('/app2'),  
  // 参数 4:传递给微应用的 props 数据
  { some: 'value' }  
);  
  
// 对象调用方式,只需要一个对象参数
// 更加清晰,易于阅读和维护,无须记住参数的顺序
registerApplication({  
  // name 参数
  name: 'app1',  
  // app 参数,必须返回 Promise
  app: () => import('src/app1/main.js'),  
  // activeWhen 参数
  activeWhen: '/app1',  
  // customProps 参数
  customProps: {  
    some: 'value',  
  }  
});  

registerApplication函数解析:注册应用,处理参数,触发reroute

export function registerApplication(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen,
  customProps
) {
// registerApplication的入参形式多样,sanitizeArguments函数是
// registerApplication的入参进行格式化,返回一个统一的格式
// 同时还会对参数的格式进行校验,不符合规范将会报错
// const registration = {
//    name: null, 注册的app的name
//    loadApp: null, 注册的app的加载方式
//    activeWhen: null, app加载的判断条件
//    customProps: null, 用户自定义参数
//  };
  const registration = sanitizeArguments(
    appNameOrConfig,
    appOrLoadApp,
    activeWhen,
    customProps
  );
// 校验是否调用了start
  if (!isStarted() && !startWarningInitialized) {
    startWarningInitialized = true;

    setTimeout(() => {
      if (!isStarted()) {
        console.warn(
          formatErrorMessage(
            1,
            __DEV__ &&
              `在加载 singleSpa 5000 毫秒后,尚未调用 singleSpa.start()。在调用 start() 之前,可以声明和加载(loaded)应用程序,但不能引导(bootstrapped)或挂载(mounted)。`
          )
        );
      }
    }, 5000);
  }
//重名校验,重名了会抛出错误
  if (getAppNames().indexOf(registration.name) !== -1)
    throw Error(
      formatErrorMessage(
        21,
        __DEV__ &&
          `There is already an app registered with name ${registration.name}`,
        registration.name
      )
    );
//apps是一个全局变量,用于存储注册的被处理过的app列表
  apps.push(
    assign(
      {
        loadErrorTime: null, // 加载错误的时间
        status: NOT_LOADED,// 加载的app初始状态
        parcels: {},
        devtools: {
          overlays: {
            options: {},
            selectors: [],
          },
        },
      },
      // 格式化后的注册应用参数
      registration
    )
  );

  if (isInBrowser) {
  // 支持 jQuery 的路由事件监听
    ensureJQuerySupport();
    reroute();
  }
}

sanitizeArguments函数解析:格式化微应用入参

function sanitizeArguments(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen,
  customProps
) {
  const usingObjectAPI = typeof appNameOrConfig === "object";

  const registration = {
    name: null,
    loadApp: null,
    activeWhen: null,
    customProps: null,
  };

  if (usingObjectAPI) {
    validateRegisterWithConfig(appNameOrConfig);
    registration.name = appNameOrConfig.name;
    registration.loadApp = appNameOrConfig.app;
    registration.activeWhen = appNameOrConfig.activeWhen;
    registration.customProps = appNameOrConfig.customProps;
  } else {
    validateRegisterWithArguments(
      appNameOrConfig,
      appOrLoadApp,
      activeWhen,
      customProps
    );
    registration.name = appNameOrConfig;
    registration.loadApp = appOrLoadApp;
    registration.activeWhen = activeWhen;
    registration.customProps = customProps;
  }

  registration.loadApp = sanitizeLoadApp(registration.loadApp);
  registration.customProps = sanitizeCustomProps(registration.customProps);
  registration.activeWhen = sanitizeActiveWhen(registration.activeWhen);

  return registration;
}

function sanitizeLoadApp(loadApp) {
  if (typeof loadApp !== "function") {
    return () => Promise.resolve(loadApp);
  }

  return loadApp;
}

function sanitizeCustomProps(customProps) {
  return customProps ? customProps : {};
}

function sanitizeActiveWhen(activeWhen) {
  let activeWhenArray = Array.isArray(activeWhen) ? activeWhen : [activeWhen];
  activeWhenArray = activeWhenArray.map((activeWhenOrPath) =>
    typeof activeWhenOrPath === "function"
      ? activeWhenOrPath
      : pathToActiveWhen(activeWhenOrPath)
  );

  return (location) =>
    activeWhenArray.some((activeWhen) => activeWhen(location));
}

export function pathToActiveWhen(path, exactMatch) {
  const regex = toDynamicPathValidatorRegex(path, exactMatch);

  return (location) => {
    // compatible with IE10
    let origin = location.origin;
    if (!origin) {
      origin = `${location.protocol}//${location.host}`;
    }
    const route = location.href
      .replace(origin, "")
      .replace(location.search, "")
      .split("?")[0];
    return regex.test(route);
  };
}

function toDynamicPathValidatorRegex(path, exactMatch) {
  let lastIndex = 0,
    inDynamic = false,
    regexStr = "^";

  if (path[0] !== "/") {
    path = "/" + path;
  }

  for (let charIndex = 0; charIndex < path.length; charIndex++) {
    const char = path[charIndex];
    const startOfDynamic = !inDynamic && char === ":";
    const endOfDynamic = inDynamic && char === "/";
    if (startOfDynamic || endOfDynamic) {
      appendToRegex(charIndex);
    }
  }

  appendToRegex(path.length);
  return new RegExp(regexStr, "i");

  function appendToRegex(index) {
    const anyCharMaybeTrailingSlashRegex = "[^/]+/?";
    const commonStringSubPath = escapeStrRegex(path.slice(lastIndex, index));

    regexStr += inDynamic
      ? anyCharMaybeTrailingSlashRegex
      : commonStringSubPath;

    if (index === path.length) {
      if (inDynamic) {
        if (exactMatch) {
          // Ensure exact match paths that end in a dynamic portion don't match
          // urls with characters after a slash after the dynamic portion.
          regexStr += "$";
        }
      } else {
        // For exact matches, expect no more characters. Otherwise, allow
        // any characters.
        const suffix = exactMatch ? "" : ".*";

        regexStr =
          // use charAt instead as we could not use es6 method endsWith
          regexStr.charAt(regexStr.length - 1) === "/"
            ? `${regexStr}${suffix}$`
            : `${regexStr}(/${suffix})?(#.*)?$`;
      }
    }

    inDynamic = !inDynamic;
    lastIndex = index;
  }

  function escapeStrRegex(str) {
    // borrowed from https://github.com/sindresorhus/escape-string-regexp/blob/master/index.js
    return str.replace(/[|\{}()[]^$+*?.]/g, "\$&");
  }
}

  start 解析

在主应用中通过 registerApplication 函数注册完所有的微应用后,会调用 start 函数启动,启动后微应用的生命周期函数才会被 single-spa 执行,而在启动之前只能对微应用进行加载和解析生命周期函数处理。start 函数执行后会标记 single-spa 的启动标识 started,后续reroute 函数可使用该标识判断是否要执行微应用的生命周期函数,如下所示:

import { reroute } from "./navigation/reroute.js";
import { patchHistoryApi } from "./navigation/navigation-events.js";
import { isInBrowser } from "./utils/runtime-environment.js";

let started = false;

export function start(opts) {
  started = true;
  if (isInBrowser) {
  //监听hashchange,popstate事件,
  //重写addEventListener,removeEventListener。
  //将 popstate/hashchange 的监听器存入capturedEventListeners
  //重写pushState,replaceState,解决原生 pushState,replaceState 不会触发          //popstate 的问题,详细了解pushState
    patchHistoryApi(opts);
    reroute();
  }
}

export function isStarted() {
  return started;
}

// 我们对history API进行了修补,以便single-spa能接收到所有pushState/replaceState调用的通知。
// 我们对addEventListener/removeEventListener进行了修补,以便能够捕获所有
//的popstate/hashchange事件监听器,
// 并延迟调用它们,直到single-spa完成应用程序的挂载/卸载
export function patchHistoryApi(opts) {
  if (historyApiIsPatched) {
    throw Error(
      formatErrorMessage(
        43,
        __DEV__ &&
          `single-spa: patchHistoryApi() was called after the history api was already patched.`
      )
    );
  }

  // True by default, as a performance optimization that reduces
  // the number of extraneous popstate events
  urlRerouteOnly =
    opts && opts.hasOwnProperty("urlRerouteOnly") ? opts.urlRerouteOnly : true;

  historyApiIsPatched = true;

  originalReplaceState = window.history.replaceState;

  // We will trigger an app change for any routing events.
  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);

  // Monkeypatch addEventListener so that we can ensure correct timing
  const originalAddEventListener = window.addEventListener;
  const originalRemoveEventListener = window.removeEventListener;
  window.addEventListener = function (eventName, fn) {
  //在这里劫持了addEventListener,如果触发了routingEventsListeningTo中的事件,会将事件回调函数存储在capturedEventListeners中,等应用切换完成后统一调用,其他类型的事件则直接执行
    if (typeof fn === "function") {
      if (
        routingEventsListeningTo.indexOf(eventName) >= 0 &&
        !find(capturedEventListeners[eventName], (listener) => listener === fn)
      ) {
        capturedEventListeners[eventName].push(fn);
        return;
      }
    }

    return originalAddEventListener.apply(this, arguments);
  };

  window.removeEventListener = function (eventName, listenerFn) {
    if (typeof listenerFn === "function") {
      if (routingEventsListeningTo.indexOf(eventName) >= 0) {
        capturedEventListeners[eventName] = capturedEventListeners[
          eventName
        ].filter((fn) => fn !== listenerFn);
      }
    }

    return originalRemoveEventListener.apply(this, arguments);
  };

  window.history.pushState = patchedUpdateState(
    window.history.pushState,
    "pushState"
  );
  window.history.replaceState = patchedUpdateState(
    originalReplaceState,
    "replaceState"
  );
}

function urlReroute() {
//arguments 类数组包含:
//{
//  0: Event, // 浏览器事件对象,监听hashchange,popstate调用urlReroute
//  length: 1
//}
  reroute([], arguments);
}

function patchedUpdateState(updateState, methodName) {
  return function () {
    const urlBefore = window.location.href;
    const result = updateState.apply(this, arguments);
    const urlAfter = window.location.href;

    if (!urlRerouteOnly || urlBefore !== urlAfter) {
      // fire an artificial popstate event so that
      // single-spa applications know about routing that
      // occurs in a different application
      window.dispatchEvent(
        createPopStateEvent(window.history.state, methodName)
      );
    }

    return result;
  };
}

function createPopStateEvent(state, originalMethodName) {
  // https://github.com/single-spa/single-spa/issues/224 and https://github.com/single-spa/single-spa-angular/issues/49
  // We need a popstate event even though the browser doesn't do one by default when you call replaceState, so that
  // all the applications can reroute. We explicitly identify this extraneous event by setting singleSpa=true and
  // singleSpaTrigger=<pushState|replaceState> on the event instance.
  let evt;
  try {
    evt = new PopStateEvent("popstate", { state });
  } catch (err) {
    // IE 11 compatibility https://github.com/single-spa/single-spa/issues/299
    // https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-html5e/bd560f47-b349-4d2c-baa8-f1560fb489dd
    evt = document.createEvent("PopStateEvent");
    evt.initPopStateEvent("popstate", false, false, state);
  }
  evt.singleSpa = true;
  evt.singleSpaTrigger = originalMethodName;
  return evt;
}

  为什么要收集hashchange,popstate事件,然后在应用加载或者切换完成后统一触发呢?

    1. 避免竞态条件(Race Conditions)
  • 问题: 当用户触发路由变化(如后退/前进)时,多个子应用可能同时响应 popstate 事件,导致:

    • 旧应用未完全卸载,新应用已开始加载。
    • 资源竞争(如全局状态、DOM 元素冲突)。
  • 解决: 统一在 应用切换完成 后触发事件监听器,确保所有子应用状态已就绪。


    2. 确保应用生命周期顺序
  • 问题: 微前端架构中,子应用的加载、卸载、挂载是异步且可能耗时的操作。若立即执行 popstate 监听器,可能导致:

    • 监听器访问未初始化的应用代码(如组件未加载)。
    • 新旧应用同时操作 DOM(如路由组件渲染冲突)。
  • 解决: 按顺序处理应用生命周期(卸载旧应用 → 加载新应用 → 挂载新应用),完成后再触发监听器。


    3. 统一的路由控制权
  • 问题: 若每个子应用独立监听 popstate 事件,可能导致:

    • 多个应用尝试修改同一路由状态。
    • 无法协调跨应用的路由跳转逻辑。
  • 解决: single-spa 作为中央控制器,统一捕获路由事件,集中决策何时执行监听器。


    4. 静默导航与状态恢复
  • 场景: 当路由切换被取消(如权限校验失败),需要还原 URL 并不触发任何副作用。
  • 解决: 通过 silentNavigation 标记,跳过事件监听器的执行,避免死循环或不必要的渲染。

    5. 性能优化
  • 优势: 批量处理事件监听器,减少重复渲染和资源加载:

    • 合并多次路由变化:短时间内多次路由跳转可合并为一次处理。
    • 按需加载资源:仅加载需要激活的应用,减少冗余请求。

    🌰 示例:用户点击后退按钮
  1. 触发 popstate 事件:浏览器通知 single-spa。
  2. 捕获事件:暂存监听器到 capturedEventListeners
  3. 卸载旧应用:清理 DOM 和资源。
  4. 加载新应用:异步获取新应用的代码和资源。
  5. 挂载新应用:渲染新应用的组件。
  6. 统一触发监听器:确保新应用已就绪后,再执行所有 popstate 监听器。

    🔧 实现机制
  • 劫持事件监听:重写 window.addEventListener,拦截 hashchange,popstate 监听器。
  • 暂存监听器:将监听器保存到 capturedEventListeners 队列。
  • 延迟执行:在应用生命周期完成后,调用 callCapturedEventListeners 触发所有暂存监听器。

    总结

    统一执行 popstate 事件的核心目标是 协调微前端架构下的复杂路由逻辑,确保:

  1. 应用状态一致性:避免新旧应用状态冲突。
  2. 生命周期可控性:按顺序处理加载、卸载、挂载。
  3. 性能与稳定性:减少冗余操作,提升用户体验。

    这种设计是微前端框架(如 single-spa)实现 可靠路由管理 的关键机制。

  浏览器原生有popstate事件,为什么要用createPopStateEvent重新创建一次popstate事件返回给dispatachEvent进行派发呢?

  1.   添加框架标记( singleSpa singleSpaTrigger
  • 标识事件来源: 手动创建的事件会附加 singleSpa=truesingleSpaTrigger="pushState"(或 replaceState)属性,用于:

    • 区分框架触发与原生触发:避免重复处理(如防止子应用误处理框架自身触发的路由事件)。
    • 调试与追踪:明确路由变化的触发来源(例如区分是用户点击后退按钮还是代码调用 pushState)。
  • 示例代码

  • evt.singleSpa = true; ``evt.singleSpaTrigger = originalMethodName; // "pushState" 或 "replaceState"


  1.   浏览器兼容性处理
  • IE 11 兼容性: IE 11 不支持 new PopStateEvent() 构造函数,需通过 document.createEvent("PopStateEvent") + initPopStateEvent 手动创建事件。
  • try { `` evt = new PopStateEvent("popstate", { state }); ``} catch (err) {// IE 11 兼容方案 evt = document.createEvent("PopStateEvent"); `` evt.initPopStateEvent("popstate", false, false, state); ``}
  • 统一行为: 无论浏览器是否支持 PopStateEvent 构造函数,都生成一个结构一致的事件对象。

  1.   确保事件对象包含正确的 state
  • 原生事件的 state 限制: 浏览器原生 popstate 事件的 state 属性由浏览器自动填充,但仅在用户操作触发时才存在。
  • 手动注入 state: 通过 createPopStateEvent(state, methodName)主动传递 state,确保子应用能访问到正确的路由状态。
  • createPopStateEvent(window.history.state, methodName);

  1.   避免与原生事件的冲突
  • 隔离框架逻辑: 如果直接派发原生 popstate 事件,可能与其他库或框架的事件监听逻辑冲突(如多个路由库同时监听 popstate)。
  • 精准控制: 手动创建的事件仅在 single-spa 的上下文中被处理,其他原生监听器不受影响。

  reroute,loadApps,performAppChanges 解析

  registerApplicationstart 都会调用 reroute 函数,该函数主要是在微应用需要发生变化时触发,它会通过 getAppChanges 判断需要变化的微应用列表,然后根据外部是否调用了 start 函数来判断执行微应用的批量加载 loadApps 还是执行所有微应用的变化 performAppChanges

reroute函数解析:

import { isStarted } from "../start.js";
import { toLoadPromise } from "../lifecycles/load.js";
import { toBootstrapPromise } from "../lifecycles/bootstrap.js";
import { toMountPromise } from "../lifecycles/mount.js";
import { toUnmountPromise } from "../lifecycles/unmount.js";
import {
  getAppStatus,
  getAppChanges,
  getMountedApps,
} from "../applications/apps.js";
import {
  callCapturedEventListeners,
  originalReplaceState,
} from "./navigation-events.js";
import { toUnloadPromise } from "../lifecycles/unload.js";
import {
  toName,
  shouldBeActive,
  NOT_MOUNTED,
  MOUNTED,
  NOT_LOADED,
  SKIP_BECAUSE_BROKEN,
} from "../applications/app.helpers.js";
import { assign } from "../utils/assign.js";
import { isInBrowser } from "../utils/runtime-environment.js";
import { formatErrorMessage } from "../applications/app-errors.js";
import { addProfileEntry } from "../devtools/profiler.js";

let appChangeUnderway = false,
  peopleWaitingOnAppChange = [],
  currentUrl = isInBrowser && window.location.href;

export function triggerAppChange() {
  // Call reroute with no arguments, intentionally
  return reroute();
}
*/*** * @description 重新路由 
* 触发的时机:
* 1. 当浏览器的 url 发生变化 *
2. 当调用 start() 方法后 * 
3. 当调用 registerApplication() 方法后 * 
4. 当调用 navigateToUrl() 方法后 *
5. 当调用 triggerAppChange() 方法后 *
6. 当调用 unloadApplication() 方法后 * 
7. 当调用 loadApplication() 方法后 * 
8. 当调用 mountRootParcel() 方法后 * 
9. 当调用 unmountRootParcel() 方法后 * 
* 总结: * retoute 主要是在微应用需要发生变化时触发, * 比如新增、删除、更新、加载、彻底卸载(unload)、
卸载、挂载应用等。 * * @export * @param [pendingPromises=[]] 等待应用变化的 Promise 数组 * @param eventArguments 事件参数,urlReroute函数会在hashchange和popstate事件触发时。调用,事件参数会透传给reroute
 export function reroute(
  pendingPromises = [],
  eventArguments,
  silentNavigation = false
) {
  if (appChangeUnderway) {
  // 如果当前正在执行 performAppChanges 处理应用变化,
  // 则将 eventArguments 存储到 peopleWaitingOnAppChange 数组中
  // 如果 performAppChanges 函数还未执行完毕,
  // 但是再次调用了 reroute 函数,
  // 那么会等待 performAppChanges 函数执行完毕
  // 在 performAppChanges 函数执行完毕后,
  // 会调用 finishUpAndReturn 函数,
  // 如果 peopleWaitingOnAppChange 数组中有数据,
  // 则会再次执行 reroute 函数
  // 因此这里主要用于延迟执行 reroute 函数
  // 将 resolve、reject、eventArguments 存储到 peopleWaitingOnAppChange 数组中
  // 当 performAppChanges 函数执行完毕后,
  // 会调用 finishUpAndReturn 函数,
  // 如果 peopleWaitingOnAppChange 数组中有数据,
  // 则会再次执行 reroute 函数*
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({
        resolve,
        reject,
        eventArguments,
      });
    });
  }

  let startTime, profilerKind;

  if (__PROFILE__) {
    startTime = performance.now();
    if (silentNavigation) {
      profilerKind = "silentNavigation";
    } else if (eventArguments) {
      profilerKind = "browserNavigation";
    } else {
      profilerKind = "triggerAppChange";
    }
  }
// 获取当前应用的变化情况
// 1. appsToUnload: 需要彻底卸载的应用
// 2. appsToUnmount: 需要卸载的应用
// 3. appsToLoad: 需要加载的应用
// 4. appsToMount: 需要挂载的应用
  const { appsToUnload, appsToUnmount, appsToLoad, appsToMount } =
    getAppChanges();
  let appsThatChanged,
    cancelPromises = [],
    oldUrl = currentUrl,
    newUrl = (currentUrl = window.location.href);

  if (isStarted()) {
    appChangeUnderway = true;
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    return performAppChanges();
  } else {
    appsThatChanged = appsToLoad;
    return loadApps();
  }
//cancelNavigation用来取消导航事件,在处理应用变化之前,会通过触发single-spa自定义事件
//fireSingleSpaEvent(
// "before-routing-event",
// getCustomEventDetail(true, { cancelNavigation })
//);
//将cancelNavigation函数作为参数传递给外界调用,外界传递一个任意值,默认为true,取消本次导航
  function cancelNavigation(val = true) {
//将调用该函数时传递的参数统一为promise存储到**cancelPromises
//performAppChanges先执行cancelPromises中的promise, Promise.all(cancelPromises).then((cancelValues) => {
// const navigationIsCanceled = cancelValues.some((v) => v);
// if (navigationIsCanceled) {符合条件取消导航,执行下一轮reroute
    const promise =
      typeof val?.then === "function" ? val : Promise.resolve(val);
    cancelPromises.push(
      promise.catch((err) => {
        console.warn(
          Error(
            formatErrorMessage(
              42,
              __DEV__ &&
              `single-spa: A cancelNavigation promise rejected with the following value: ${err}`
            )
          )
        );
        console.warn(err);

        // Interpret a Promise rejection to mean that the navigation should not be canceled
        return false;
      })
    );
  }
//加载微应用
  function loadApps() {
    return Promise.resolve().then(() => {
      const loadPromises = appsToLoad.map(toLoadPromise);
      let succeeded;

      return (
        Promise.all(loadPromises)
          .then(callAllEventListeners)
          // there are no mounted apps, before start() is called, so we always return []
          .then(() => {
            if (__PROFILE__) {
              succeeded = true;
            }

            return [];
          })
          .catch((err) => {
            if (__PROFILE__) {
              succeeded = false;
            }

            callAllEventListeners();
            throw err;
          })
          .finally(() => {
            if (__PROFILE__) {
              addProfileEntry(
                "routing",
                "loadApps",
                profilerKind,
                startTime,
                performance.now(),
                succeeded
              );
            }
          })
      );
    });
  }

  function performAppChanges() {
    return Promise.resolve().then(() => {
      // https://github.com/single-spa/single-spa/issues/545
      fireSingleSpaEvent(
        appsThatChanged.length === 0
          ? "before-no-app-change"
          : "before-app-change",
        getCustomEventDetail(true)
      );

      fireSingleSpaEvent(
        "before-routing-event",
        getCustomEventDetail(true, { cancelNavigation })
      );

      return Promise.all(cancelPromises).then((cancelValues) => {
        const navigationIsCanceled = cancelValues.some((v) => v);

        if (navigationIsCanceled) {
          //用户通过监听**before-routing-event事件**取消导航
          originalReplaceState.call(
            window.history,
            history.state,
            "",
            oldUrl.substring(location.origin.length)
          );

          // Single-spa's internal tracking of current url needs to be updated after the url change above
          currentUrl = location.href;

          // necessary for the reroute function to know that the current reroute is finished
          appChangeUnderway = false;

          if (__PROFILE__) {
            addProfileEntry(
              "routing",
              "navigationCanceled",
              profilerKind,
              startTime,
              performance.now(),
              true
            );
          }

          // Tell single-spa to reroute again, this time with the url set to the old URL
          return reroute(pendingPromises, eventArguments, true);
        }
        //用户没有取消导航继续处理应用的变化
        const unloadPromises = appsToUnload.map(toUnloadPromise);

        const unmountUnloadPromises = appsToUnmount
          .map(toUnmountPromise)
          .map((unmountPromise) => unmountPromise.then(toUnloadPromise));

        const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);

        const unmountAllPromise = Promise.all(allUnmountPromises);

        let unmountFinishedTime;

        unmountAllPromise.then(
          () => {
            if (__PROFILE__) {
              unmountFinishedTime = performance.now();

              addProfileEntry(
                "routing",
                "unmountAndUnload",
                profilerKind,
                startTime,
                performance.now(),
                true
              );
            }
            fireSingleSpaEvent(
              "before-mount-routing-event",
              getCustomEventDetail(true)
            );
          },
          (err) => {
            if (__PROFILE__) {
              addProfileEntry(
                "routing",
                "unmountAndUnload",
                profilerKind,
                startTime,
                performance.now(),
                true
              );
            }

            throw err;
          }
        );

        /* We load and bootstrap apps while other apps are unmounting, but we
         * wait to mount the app until all apps are finishing unmounting
         */
        const loadThenMountPromises = appsToLoad.map((app) => {
          return toLoadPromise(app).then((app) =>
            tryToBootstrapAndMount(app, unmountAllPromise)
          );
        });

        /* These are the apps that are already bootstrapped and just need
         * to be mounted. They each wait for all unmounting apps to finish up
         * before they mount.
         */
        const mountPromises = appsToMount
          .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
          .map((appToMount) => {
            return tryToBootstrapAndMount(appToMount, unmountAllPromise);
          });
        return unmountAllPromise
          .catch((err) => {
            callAllEventListeners();
            throw err;
          })
          .then(() => {
            /*现在需要卸载的应用程序已经卸载,它们的DOM导航
  *事件(如hashchange或popstate)应该已被清理。所以很安全
  *让剩余的捕获事件侦听器处理DOM事件。
  */
            callAllEventListeners();

            return Promise.all(loadThenMountPromises.concat(mountPromises))
              .catch((err) => {
                pendingPromises.forEach((promise) => promise.reject(err));
                throw err;
              })
              .then(finishUpAndReturn)
              .then(
                () => {
                  if (__PROFILE__) {
                    addProfileEntry(
                      "routing",
                      "loadAndMount",
                      profilerKind,
                      unmountFinishedTime,
                      performance.now(),
                      true
                    );
                  }
                },
                (err) => {
                  if (__PROFILE__) {
                    addProfileEntry(
                      "routing",
                      "loadAndMount",
                      profilerKind,
                      unmountFinishedTime,
                      performance.now(),
                      false
                    );
                  }

                  throw err;
                }
              );
          });
      });
    });
  }

  function finishUpAndReturn() {
    const returnValue = getMountedApps();
    pendingPromises.forEach((promise) => promise.resolve(returnValue));

    try {
      const appChangeEventName =
        appsThatChanged.length === 0 ? "no-app-change" : "app-change";
      fireSingleSpaEvent(appChangeEventName, getCustomEventDetail());
      fireSingleSpaEvent("routing-event", getCustomEventDetail());
    } catch (err) {
      /* We use a setTimeout because if someone else's event handler throws an error, single-spa
       * needs to carry on. If a listener to the event throws an error, it's their own fault, not
       * single-spa's.
       */
      setTimeout(() => {
        throw err;
      });
    }

    /* Setting this allows for subsequent calls to reroute() to actually perform
     * a reroute instead of just getting queued behind the current reroute call.
     * We want to do this after the mounting/unmounting is done but before we
     * resolve the promise for the `reroute` function.
     */
    appChangeUnderway = false;

    if (peopleWaitingOnAppChange.length > 0) {
      /* While we were rerouting, someone else triggered another reroute that got queued.
       * So we need reroute again.
       */
      const nextPendingPromises = peopleWaitingOnAppChange;
      peopleWaitingOnAppChange = [];
      reroute(nextPendingPromises);
    }

    return returnValue;
  }

  /* We need to call all event listeners that have been delayed because they were
   * waiting on single-spa. This includes haschange and popstate events for both
   * the current run of performAppChanges(), but also all of the queued event listeners.
   * We want to call the listeners in the same order as if they had not been delayed by
   * single-spa, which means queued ones first and then the most recent one.
   */
  function callAllEventListeners() {
    //在静默导航期间(当导航被取消并且我们将返回到旧URL时),
    //我们不应该触发任何popstate/hashchange事件
    if (!silentNavigation) {
      pendingPromises.forEach((pendingPromise) => {
        callCapturedEventListeners(pendingPromise.eventArguments);
      });

      callCapturedEventListeners(eventArguments);
    }
  }

  function getCustomEventDetail(isBeforeChanges = false, extraProperties) {
    const newAppStatuses = {};
    const appsByNewStatus = {
      // for apps that were mounted
      [MOUNTED]: [],
      // for apps that were unmounted
      [NOT_MOUNTED]: [],
      // apps that were forcibly unloaded
      [NOT_LOADED]: [],
      // apps that attempted to do something but are broken now
      [SKIP_BECAUSE_BROKEN]: [],
    };

    if (isBeforeChanges) {
      appsToLoad.concat(appsToMount).forEach((app, index) => {
        addApp(app, MOUNTED);
      });
      appsToUnload.forEach((app) => {
        addApp(app, NOT_LOADED);
      });
      appsToUnmount.forEach((app) => {
        addApp(app, NOT_MOUNTED);
      });
    } else {
      appsThatChanged.forEach((app) => {
        addApp(app);
      });
    }

    const result = {
      detail: {
        newAppStatuses,
        appsByNewStatus,
        totalAppChanges: appsThatChanged.length,
        originalEvent: eventArguments?.[0],
        oldUrl,
        newUrl,
      },
    };

    if (extraProperties) {
      assign(result.detail, extraProperties);
    }

    return result;

    function addApp(app, status) {
      const appName = toName(app);
      status = status || getAppStatus(appName);
      newAppStatuses[appName] = status;
      const statusArr = (appsByNewStatus[status] =
        appsByNewStatus[status] || []);
      statusArr.push(appName);
    }
  }

  function fireSingleSpaEvent(name, eventProperties) {
    // During silent navigation (caused by navigation cancelation), we should not
    // fire any single-spa events
    if (!silentNavigation) {
      window.dispatchEvent(
        new CustomEvent(`single-spa:${name}`, eventProperties)
      );
    }
  }
}

/**
 * Let's imagine that some kind of delay occurred during application loading.
 * The user without waiting for the application to load switched to another route,
 * this means that we shouldn't bootstrap and mount that application, thus we check
 * twice if that application should be active before bootstrapping and mounting.
 * https://github.com/single-spa/single-spa/issues/524
 */
function tryToBootstrapAndMount(app, unmountAllPromise) {
  if (shouldBeActive(app)) {
    return toBootstrapPromise(app).then((app) =>
      unmountAllPromise.then(() =>
        shouldBeActive(app) ? toMountPromise(app) : app
      )
    );
  } else {
    return unmountAllPromise.then(() => app);
  }
}

  single-spa的生命周期

  生命周期汇总:

  1. toBootstrapPromise:应用变化时,将要卸载的应用处理干净后加载本次变化要加载的应用后进行初始化调用
  2. toLoadPromise:应用注册完成加载时候会调用,应用变化时候加载会调用一次
  3. toMountPromise:在调用toBootstrapPromise 后会紧接着判断该app是否需要激活,需要则直接调用toMountPromise挂载应用
  4. toUnloadPromise:在应用变化时,在判断不取消本次导航过后,toUnloadPromise最先被调用
  5. toUnmountPromisetoUnloadPromise 调用后紧接着就调用toUnmountPromise , 卸载完成后还要立马调用toUnloadPromise进行移除,同时single-spa会将toUnloadPromise,toUnmountPromise进行合并,确保操作全部完成后才继续加载,初始化,挂载等生命周期操作

toBootstrapPromise 解析

reasonableTime是一个通用函数,主要是负责生命周期函数的超时处理,超时时间用户可以自己配置,single-spa也有默认值


export function reasonableTime(appOrParcel, lifecycle) {
  const timeoutConfig = appOrParcel.timeouts[lifecycle];
  const warningPeriod = timeoutConfig.warningMillis;
  const type = objectType(appOrParcel);

  return new Promise((resolve, reject) => {
    let finished = false;
    let errored = false;

    appOrParcel[lifecycle](getProps(appOrParcel))
      .then((val) => {
        finished = true;
        resolve(val);
      })
      .catch((val) => {
        finished = true;
        reject(val);
      });

    setTimeout(() => maybeTimingOut(1), warningPeriod);
    setTimeout(() => maybeTimingOut(true), timeoutConfig.millis);

    const errMsg = formatErrorMessage(
      31,
      __DEV__ &&
        `Lifecycle function ${lifecycle} for ${type} ${toName(
          appOrParcel
        )} lifecycle did not resolve or reject for ${timeoutConfig.millis} ms.`,
      lifecycle,
      type,
      toName(appOrParcel),
      timeoutConfig.millis
    );

    function maybeTimingOut(shouldError) {
    //finished初始状态为false当生命周期函数执行完毕后,finished会被置为true
      if (!finished) {
      //说明生命周期还没被执行完成,shouldError如果为true,说明已经执行超时
        if (shouldError === true) {
          errored = true;
          if (timeoutConfig.dieOnTimeout) {
            reject(Error(errMsg));
          } else {
            console.error(errMsg);
            //don't resolve or reject, we're waiting this one out
          }
        } else if (!errored) {
        //shouldError为false,还未超时,重新定时setTimeout,执行下一次超时轮询逻辑
          const numWarnings = shouldError;
          const numMillis = numWarnings * warningPeriod;
          console.warn(errMsg);
          if (numMillis + warningPeriod < timeoutConfig.millis) {
            setTimeout(() => maybeTimingOut(numWarnings + 1), warningPeriod);
          }
        }
      }
    }
  });
}

toBootstrapPromise生命周期函数解析:

import {
  NOT_BOOTSTRAPPED,
  BOOTSTRAPPING,
  NOT_MOUNTED,
  SKIP_BECAUSE_BROKEN,
  toName,
  isParcel,
} from "../applications/app.helpers.js";
import { reasonableTime } from "../applications/timeouts.js";
import { handleAppError, transformErr } from "../applications/app-errors.js";
import { addProfileEntry } from "../devtools/profiler.js";

export function toBootstrapPromise(appOrParcel, hardFail) {
  let startTime, profileEventType;

  return Promise.resolve().then(() => {
    if (appOrParcel.status !== NOT_BOOTSTRAPPED) {
      return appOrParcel;
    }

    if (__PROFILE__) {
      profileEventType = isParcel(appOrParcel) ? "parcel" : "application";
      startTime = performance.now();
    }

    appOrParcel.status = BOOTSTRAPPING;

    if (!appOrParcel.bootstrap) {
      // Default implementation of bootstrap
      return Promise.resolve().then(successfulBootstrap);
    }

    return reasonableTime(appOrParcel, "bootstrap")
      .then(successfulBootstrap)
      .catch((err) => {
        if (__PROFILE__) {
          addProfileEntry(
            profileEventType,
            toName(appOrParcel),
            "bootstrap",
            startTime,
            performance.now(),
            false
          );
        }

        if (hardFail) {
          throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
        } else {
          handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
          return appOrParcel;
        }
      });
  });

  function successfulBootstrap() {
    appOrParcel.status = NOT_MOUNTED;

    if (__PROFILE__) {
      addProfileEntry(
        profileEventType,
        toName(appOrParcel),
        "bootstrap",
        startTime,
        performance.now(),
        true
      );
    }

    return appOrParcel;
  }
}

toLoadPromise 解析

import {
  LOAD_ERROR,
  NOT_BOOTSTRAPPED,
  LOADING_SOURCE_CODE,
  SKIP_BECAUSE_BROKEN,
  NOT_LOADED,
  objectType,
  toName,
} from "../applications/app.helpers.js";
import { ensureValidAppTimeouts } from "../applications/timeouts.js";
import {
  handleAppError,
  formatErrorMessage,
} from "../applications/app-errors.js";
import {
  flattenFnArray,
  smellsLikeAPromise,
  validLifecycleFn,
} from "./lifecycle.helpers.js";
import { getProps } from "./prop.helpers.js";
import { assign } from "../utils/assign.js";
import { addProfileEntry } from "../devtools/profiler.js";

export function toLoadPromise(appOrParcel) {
  return Promise.resolve().then(() => {
    if (appOrParcel.loadPromise) {
     // 如果 app.loadPromise 存在,
    // 直接返回 app.loadPromise
    // 这里可以确保同一个 app 只会执行一次 loadApp 方法

    // 例如注册微应用时会调用 loadApps 方法,
    // 会执行微应用的 toLoadPromise,
    // 此时会缓存 app.loadPromise
    
    // 而启动 start 函数最终调用 performAppChanges 时还会执行微应用的 toLoadPromise
    // 为了避免重复执行 app.loadPromise 方法,
    // 这里会直接返回 app.loadPromise(Promise 对象)
      return appOrParcel.loadPromise;
    }
    // 如果 app.status 不是 NOT_LOADED 和 LOAD_ERROR,直接返回 app
    if (
      appOrParcel.status !== NOT_LOADED &&
      appOrParcel.status !== LOAD_ERROR
    ) {
      return appOrParcel;
    }

    let startTime;

    if (__PROFILE__) {
      startTime = performance.now();
    }
    // 将 app.status 设置为 LOADING_SOURCE_CODE
    appOrParcel.status = LOADING_SOURCE_CODE;

    let appOpts, isUserErr;
    // 使用 app.loadPromise 缓存 app 的加载,
    // 避免在 loadApps 以及 performAppChanges 时重复加载
    return (appOrParcel.loadPromise = Promise.resolve()
      .then(() => {
        // 这里的 loadApp 其实就是 registerApplication 的第二个参数
        // 在主应用中使用 window.fetch 获取子应用的资源,
        // 执行后需要返回 Promise,
        // 并且返回的是子应用的生命周期函数对象
        const loadPromise = appOrParcel.loadApp(getProps(appOrParcel));
        if (!smellsLikeAPromise(loadPromise)) {
          //如果loadPromise不是promise函数,抛出错误
          isUserErr = true;
          throw Error(
            formatErrorMessage(
              33,
              __DEV__ &&
                `single-spa loading function did not return a promise. Check the second argument to registerApplication('${toName(
                  appOrParcel
                )}', loadingFunction, activityFunction)`,
              toName(appOrParcel)
            )
          );
        }
         // 这里的 val 其实就是 loadApp 的 Promise 返回值
        // 也就是 registerApplication 的第二个参数 app 的返回值
        
        // 例如:() => import("react-micro-app"),
        // 返回的是一个 Promise
        
        // 例如:() => Promise.resolve({ bootstrap: async () => {}, mount, unmount }),
        // 返回的是一个 Promise
        
        // 所以 val 就是各个子应用的生命周期函数组成的对象,
        // 例如:{ bootstrap: async () => {}, mount, unmount }
        return loadPromise.then((val) => {
          appOrParcel.loadErrorTime = null;

          appOpts = val;

          let validationErrMessage, validationErrCode;
          //以下就是对生命周期的格式进行检测,判断appOpts, appOpts 的 bootstrap、mount、unmount 是否符合要求
          if (typeof appOpts !== "object") {
            validationErrCode = 34;
            if (__DEV__) {
              validationErrMessage = `does not export anything`;
            }
          }

          if (
            // ES Modules don't have the Object prototype
            Object.prototype.hasOwnProperty.call(appOpts, "bootstrap") &&
            !validLifecycleFn(appOpts.bootstrap)
          ) {
            validationErrCode = 35;
            if (__DEV__) {
              validationErrMessage = `does not export a valid bootstrap function or array of functions`;
            }
          }

          if (!validLifecycleFn(appOpts.mount)) {
            validationErrCode = 36;
            if (__DEV__) {
              validationErrMessage = `does not export a mount function or array of functions`;
            }
          }

          if (!validLifecycleFn(appOpts.unmount)) {
            validationErrCode = 37;
            if (__DEV__) {
              validationErrMessage = `does not export a unmount function or array of functions`;
            }
          }

          const type = objectType(appOpts);

          if (validationErrCode) {
            let appOptsStr;
            try {
              appOptsStr = JSON.stringify(appOpts);
            } catch {}
            console.error(
              formatErrorMessage(
                validationErrCode,
                __DEV__ &&
                  `The loading function for single-spa ${type} '${toName(
                    appOrParcel
                  )}' resolved with the following, which does not have bootstrap, mount, and unmount functions`,
                type,
                toName(appOrParcel),
                appOptsStr
              ),
              appOpts
            );
            handleAppError(
              validationErrMessage,
              appOrParcel,
              SKIP_BECAUSE_BROKEN
            );
            return appOrParcel;
          }

          if (appOpts.devtools && appOpts.devtools.overlays) {
            appOrParcel.devtools.overlays = assign(
              {},
              appOrParcel.devtools.overlays,
              appOpts.devtools.overlays
            );
          }
          // 设置 app 的状态为 NOT_BOOTSTRAPPED
          appOrParcel.status = NOT_BOOTSTRAPPED;
          // 将 appOpts 中的周期函数扁平化
          appOrParcel.bootstrap = flattenFnArray(appOpts, "bootstrap");
          appOrParcel.mount = flattenFnArray(appOpts, "mount");
          appOrParcel.unmount = flattenFnArray(appOpts, "unmount");
          appOrParcel.unload = flattenFnArray(appOpts, "unload");
          appOrParcel.timeouts = ensureValidAppTimeouts(appOpts.timeouts);
          // 删除 app.loadPromise,表明 app.loadPromise 已经执行完成
          // 下一次执行 toLoadPromise 时会重新执行 app.loadPromise
          delete appOrParcel.loadPromise;

          if (__PROFILE__) {
            addProfileEntry(
              "application",
              toName(appOrParcel),
              "load",
              startTime,
              performance.now(),
              true
            );
          }

          return appOrParcel;
        });
      })
      .catch((err) => {
        delete appOrParcel.loadPromise;

        let newStatus;
        if (isUserErr) {
          newStatus = SKIP_BECAUSE_BROKEN;
        } else {
          newStatus = LOAD_ERROR;
          appOrParcel.loadErrorTime = new Date().getTime();
        }
        handleAppError(err, appOrParcel, newStatus);

        if (__PROFILE__) {
          addProfileEntry(
            "application",
            toName(appOrParcel),
            "load",
            startTime,
            performance.now(),
            false
          );
        }

        return appOrParcel;
      }));
  });
}

为什么要缓存了loadPromise后完成加载后又删除loadPromise呢?

  1. 防止并发重复加载
场景

假设多个地方同时调用 toLoadPromise(app)(例如路由变化和手动触发同时发生),若未缓存 loadPromise

  • 第一次调用触发加载逻辑(如网络请求、初始化资源)。
  • 第二次调用再次触发相同逻辑,导致重复加载。
解决方案

appOrParcel.loadPromise = Promise.resolve().then(() => {// 加载逻辑... });

  • 首次调用:创建 loadPromise 并开始加载。
  • 后续调用:直接返回已存在的 loadPromise,复用同一个 Promise。

  1. 保证异步操作一致性
场景

应用加载是一个异步过程(如动态 import() 或网络请求),需要确保所有调用者等待同一个结果。

// 示例:两个并发调用 ``const promise1 = toLoadPromise(app); const promise2 = toLoadPromise(app); `` // 两者应指向同一个 Promise ``console.log(promise1 === promise2); // true

优势
  • 避免资源浪费:防止多次加载同一应用。
  • 状态一致性:所有调用者获得同一结果(成功或失败)。

  1. 缓存与删除的协作
阶段 操作 目的
加载中 缓存 loadPromise 防止并发重复加载
加载完成 删除 loadPromise 允许后续重新加载(如错误恢复或热更新)

toMountPromise 解析

import {
  NOT_MOUNTED,
  MOUNTED,
  SKIP_BECAUSE_BROKEN,
  MOUNTING,
  toName,
  isParcel,
} from "../applications/app.helpers.js";
import { handleAppError, transformErr } from "../applications/app-errors.js";
import { reasonableTime } from "../applications/timeouts.js";
import CustomEvent from "custom-event";
import { toUnmountPromise } from "./unmount.js";
import { addProfileEntry } from "../devtools/profiler.js";

let beforeFirstMountFired = false;
let firstMountFired = false;

export function toMountPromise(appOrParcel, hardFail) {
  return Promise.resolve().then(() => {
    if (appOrParcel.status !== NOT_MOUNTED) {
      return appOrParcel;
    }

    let startTime, profileEventType;

    if (__PROFILE__) {
      profileEventType = isParcel(appOrParcel) ? "parcel" : "application";
      startTime = performance.now();
    }
    // 如果是第一次挂载子应用,
    // 触发 single-spa:before-first-mount 事件
    if (!beforeFirstMountFired) {
      window.dispatchEvent(new CustomEvent("single-spa:before-first-mount"));
      beforeFirstMountFired = true;
    }
    
    appOrParcel.status = MOUNTING;

    return reasonableTime(appOrParcel, "mount")
      .then(() => {
        appOrParcel.status = MOUNTED;
        // 如果是第一次挂载子应用,触发 single-spa:first-mount 事件
        if (!firstMountFired) {
          window.dispatchEvent(new CustomEvent("single-spa:first-mount"));
          firstMountFired = true;
        }

        if (__PROFILE__) {
          addProfileEntry(
            profileEventType,
            toName(appOrParcel),
            "mount",
            startTime,
            performance.now(),
            true
          );
        }

        return appOrParcel;
      })
      .catch((err) => {
        //如果我们无法挂载appOrParcel,我们应该在放入SKIP_BEAUSE_BROKEN之前尝试卸载它
        //我们暂时将appOrParcel置于MOUNTED状态,以便toUnmountPromise实际尝试卸载它
        //而不是仅仅做一个无操作。
        appOrParcel.status = MOUNTED;
        return toUnmountPromise(appOrParcel, true).then(
          setSkipBecauseBroken,
          setSkipBecauseBroken
        );

        function setSkipBecauseBroken() {
          if (__PROFILE__) {
            addProfileEntry(
              profileEventType,
              toName(appOrParcel),
              "mount",
              startTime,
              performance.now(),
              false
            );
          }

          if (!hardFail) {
            handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
            return appOrParcel;
          } else {
            throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
          }
        }
      });
  });
}

toUnloadPromise 解析

import {
  NOT_MOUNTED,
  UNLOADING,
  NOT_LOADED,
  LOAD_ERROR,
  SKIP_BECAUSE_BROKEN,
  toName,
} from "../applications/app.helpers.js";
import { handleAppError } from "../applications/app-errors.js";
import { reasonableTime } from "../applications/timeouts.js";
import { addProfileEntry } from "../devtools/profiler.js";

const appsToUnload = {};

export function toUnloadPromise(appOrParcel) {
  return Promise.resolve().then(() => {
    const unloadInfo = appsToUnload[toName(appOrParcel)];

    if (!unloadInfo) {
      /* No one has called unloadApplication for this app,
       */
      return appOrParcel;
    }

    if (appOrParcel.status === NOT_LOADED) {
     /*此应用程序已卸载。我们只需要清理一下
*任何仍然认为我们需要卸载应用程序的东西。
*/
      finishUnloadingApp(appOrParcel, unloadInfo);
      return appOrParcel;
    }

    if (appOrParcel.status === UNLOADING) {
     /*unloadApplication和reroute都希望卸载此应用程序。
*不过,这只需要做一次。
*/
      return unloadInfo.promise.then(() => appOrParcel);
    }

    if (
      appOrParcel.status !== NOT_MOUNTED &&
      appOrParcel.status !== LOAD_ERROR
    ) {
     /*在挂载之前,无法卸载该应用程序。*/
      return appOrParcel;
    }

    let startTime;

    if (__PROFILE__) {
      startTime = performance.now();
    }

    const unloadPromise =
      appOrParcel.status === LOAD_ERROR
        ? Promise.resolve()
        : reasonableTime(appOrParcel, "unload");

    appOrParcel.status = UNLOADING;

    return unloadPromise
      .then(() => {
        if (__PROFILE__) {
          addProfileEntry(
            "application",
            toName(appOrParcel),
            "unload",
            startTime,
            performance.now(),
            true
          );
        }

        finishUnloadingApp(appOrParcel, unloadInfo);

        return appOrParcel;
      })
      .catch((err) => {
        if (__PROFILE__) {
          addProfileEntry(
            "application",
            toName(appOrParcel),
            "unload",
            startTime,
            performance.now(),
            false
          );
        }

        errorUnloadingApp(appOrParcel, unloadInfo, err);

        return appOrParcel;
      });
  });
}

function finishUnloadingApp(app, unloadInfo) {
  delete appsToUnload[toName(app)];

  // Unloaded apps don't have lifecycles
  delete app.bootstrap;
  delete app.mount;
  delete app.unmount;
  delete app.unload;

  app.status = NOT_LOADED;

  /* resolve the promise of whoever called unloadApplication.
   * This should be done after all other cleanup/bookkeeping
   */
  unloadInfo.resolve();
}

function errorUnloadingApp(app, unloadInfo, err) {
  delete appsToUnload[toName(app)];

  // Unloaded apps don't have lifecycles
  delete app.bootstrap;
  delete app.mount;
  delete app.unmount;
  delete app.unload;

  handleAppError(err, app, SKIP_BECAUSE_BROKEN);
  unloadInfo.reject(err);
}

export function addAppToUnload(app, promiseGetter, resolve, reject) {
  appsToUnload[toName(app)] = { app, resolve, reject };
  Object.defineProperty(appsToUnload[toName(app)], "promise", {
    get: promiseGetter,
  });
}

export function getAppUnloadInfo(appName) {
  return appsToUnload[appName];
}

CSS Transform 和父元素撑开问题

CSS Transform 和父元素撑开问题,个人理解

最近在做前端布局的时候,我遇到了一个小坑,想记录一下:

  • 我的父元素设置了 min-height: 200px
  • 子元素本来希望通过自己的高度撑开父元素。
  • 直接给子元素 height 大于 200px,父元素就能撑开,没问题。
  • 但是如果用 transform: scale() 放大子元素,父元素就不会跟着变高,看起来很奇怪。

这个现象源于 CSS 布局和 transform 的机制不一样


我理解的原理

1. transform 不算在布局里

transform 只是在 渲染阶段起作用,它改变的是视觉效果,而不是布局尺寸。

  • 布局阶段计算父元素高度的时候,用的是 offsetHeightclientHeight
  • 变换放大只会改变视觉高度,但不会改变这些 layout 属性。

2. DevTools 看起来容易迷惑

Chrome 里 Elements 面板显示的 Computed Height 有时候是视觉高度(也就是 transform 后的),所以你会看到 transform: scale() 后的高度,但父元素撑开还是原来的高度。

3. 父元素撑开的逻辑

父元素高度其实是这样计算的: parent.height = max(所有子元素 layoutHeight, parent.min-height)

因为 transform 不改变 layoutHeight,所以父元素不会随视觉高度撑开。

4. 类似的属性也有同样的问题

  • transform: translate() 移动视觉位置,但不会改变父元素布局。
  • transform: rotate() 旋转也不会影响父元素大小。
  • transform: skew() 或者 scaleX/scaleY 也是一样。
  • 总的来说,所有只改变视觉效果但不改 layout 的 CSS 属性都不会撑开父元素

示例代码

<div class="parent" style="min-height:200px; background:lightblue; padding:10px; border:2px solid blue;">
  <div class="child" style="width:100px; height:100px; background:tomato; margin:10px 0; transform: scale(2); transform-origin: top left;"></div>
</div>
<div class="info" id="info"></div>
<script>
  const child = document.querySelector('.child');
  const parent = document.querySelector('.parent');
  const info = document.getElementById('info');

  function updateInfo() {
    const layoutHeight = child.offsetHeight;
    const clientHeight = child.clientHeight;
    const visualHeight = child.getBoundingClientRect().height;
    const parentHeight = parent.offsetHeight;

    info.innerHTML = `
      子元素 offsetHeight (布局高度) = ${layoutHeight}px<br>
      子元素 clientHeight (布局高度,包括 padding) = ${clientHeight}px<br>
      子元素视觉高度 (getBoundingClientRect) = ${visualHeight}px<br>
      父元素 offsetHeight = ${parentHeight}px
    `;
  }

  updateInfo();
  window.addEventListener('resize', updateInfo);
</script>

🔹 效果图

image.png


我的解决方案和想法

  • 最简单的方法是直接用真实高度,比如 height 或 padding,让父元素被撑开。
  • 如果一定要用 transform 放大,可以用 JS 动态拿 getBoundingClientRect().height,然后同步给父元素。
  • 另外,不推荐但是可以考虑 zoom(非标准属性,浏览器兼容性不好)。

总结一下就是,我现在理解是:

  • transform 放大只是视觉上的变化,不改变实际布局。
  • 父元素撑不起来完全正常。
  • 类似 translate、rotate、skew 等属性也有同样的问题。
  • 想让父元素跟随视觉变化,得用真实布局尺寸或者 JS 同步。

React 中的代数效应:从概念到 Fiber 架构的落地

参考:《React 技术揭秘》 by 卡颂

一、前言:React,不只是“快”

React 团队做架构升级,从来不是为了单纯的“更快”。
如果只是性能,他们完全可以优化 reconciliation 算法或者 diff 策略。

他们真正追求的,是**“控制时间”**——
让 UI 的更新可被中断、调度、恢复,就像一位懂分寸的画家,
知道什么时候该收笔,什么时候该补色。

这正是 React Fiber 想要实现的哲学。

而理解它的钥匙,藏在一个看似“学术味”的概念里:
👉 代数效应(Algebraic Effects)


二、代数效应:一个让函数更“有礼貌”的思想

简单来说,代数效应解决的是一个老大难问题:

当一个函数既要保持纯净逻辑,又要处理副作用时,该怎么办?

我们先看一个极简例子👇

function getTotalPicNum(user1, user2) {
  const picNum1 = getPicNum(user1);
  const picNum2 = getPicNum(user2);
  return picNum1 + picNum2;
}

逻辑简单到极致——加法而已。
但一旦 getPicNum 变成异步(比如去服务器查图片数),
整条函数调用链就被 async/await 感染了:

async function getTotalPicNum(user1, user2) {
  const picNum1 = await getPicNum(user1);
  const picNum2 = await getPicNum(user2);
  return picNum1 + picNum2;
}

于是,你的整个项目从同步世界坠入了“Promise 地狱”。
这就像一场小感冒引发了全公司的核酸检测。

代数效应的思路是这样的:

副作用由外部捕获和恢复,函数内部依然保持纯净。

为了说明,我们用一段虚构语法来模拟它的思想(不是 JS 代码,只是概念演示):

function getPicNum(name) {
  const picNum = perform name; // 执行副作用
  return picNum;
}

try {
  getTotalPicNum('kaSong', 'xiaoMing');
} handle (who) {
  switch (who) {
    case 'kaSong':
      resume with 230;
    case 'xiaoMing':
      resume with 122;
  }
}

这里的 perform 会触发外层的 handle
resume 再将结果带回中断点继续执行。

也就是说,函数逻辑和副作用的执行被“分离”了。
听起来是不是有点像 React 的 “render” 与 “commit” 阶段?


三、Fiber 登场:React 的代数效应工程实现

React Fiber 是 React 团队为了解决同步递归更新无法中断的问题而重写的协调器。

换句话说,它是 React 的一次“灵魂重构”。

Fiber 的核心目标是:

  • 支持任务分片与优先级调度
  • 允许任务中断与恢复
  • 恢复后能复用中间结果

或者更通俗点说:

以前 React 渲染是一口气吃完的火锅;
Fiber 让它可以夹一口肉,放下筷子接个电话,再回来继续吃。


🌿 Fiber 是什么?

Fiber(纤程)并不是 React 发明的词。
它早就出现在计算机领域中,与进程(Process)、线程(Thread)、协程(Coroutine)并列。

在 JavaScript 世界里,协程的实现是 Generator
所以我们可以说:

Fiber 是 React 在 JS 里,用链表结构模拟协程的一种实现。

简单理解:
Fiber 就是一种可中断、可恢复的执行模型。


四、Fiber 节点结构:链表串起的「可中断栈」

Fiber 架构中,每个 React Element 对应一个 Fiber 节点,
每个 Fiber 节点都是一个「工作单元」。

结构大致如下👇

FiberNode {
  type,          // 对应组件类型
  key,           // key
  return,        // 父 Fiber
  child,         // 第一个子 Fiber
  sibling,       // 兄弟 Fiber
  pendingProps,  // 即将更新的 props
  memoizedProps, // 已渲染的 props
  stateNode,     // 对应的 DOM 或 class 实例
}

它的核心是用链表结构,模拟函数调用栈
这样 React 就能“暂停栈帧”,在浏览器空闲时恢复执行。

想象一个任务循环(伪代码):

while (workInProgress && !shouldYield()) {
  workInProgress = performUnitOfWork(workInProgress);
}

shouldYield() 会检测浏览器时间是否用完。
如果主线程要去干别的事(比如动画、用户输入),React 会体贴地暂停。

这,就是 React Fiber 的“可中断渲染”精髓。


五、那 Generator 呢?为什么不用它?

有人会问:

“Generator 不也能暂停和恢复吗?为什么 React 要造轮子?”

确实,Generator 曾经是候选方案。
但它的问题在于两点:

  1. 传染性太强:用过 yield 的函数,整条调用链都得改。
  2. 上下文绑定太死:一旦被中断,就难以灵活恢复特定任务。

举个例子:

function* doWork(A, B, C) {
  var x = doExpensiveWorkA(A);
  yield;
  var y = x + doExpensiveWorkB(B);
  yield;
  var z = y + doExpensiveWorkC(C);
  return z;
}

如果任务中途被打断,或者来个更高优的任务插队,
xy 的计算状态就乱套了。

而 Fiber 则通过链表结构,把中间状态封装在节点上,
可以安全暂停、恢复、复用。


六、Fiber 调度:React 的“任务分片大师”

React Fiber 的渲染过程分两阶段:

  1. Render 阶段(可中断) :生成 Fiber 树,计算变更。
  2. Commit 阶段(不可中断) :执行 DOM 操作,提交更新。

示意图如下:

Render(可中断) ----> Commit(同步)
     ↑                    ↓
   调度器控制         应用更新

这正是代数效应的工程化体现:
逻辑阶段(Render)可以被中断和恢复,
副作用阶段(Commit)则由系统集中处理。


七、Hooks:代数效应的另一种体现

再看看 Hook:

function App() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

这里的 useStateuseReducer 等 Hook,
其实也是代数效应思想的体现:

组件逻辑中不关心状态保存的机制,只管声明“我需要一个状态”。

React 内部帮你处理 Fiber 节点中的状态链表。
你只需关心「逻辑」而非「调度」,
就像写同步代码一样,优雅得让人上瘾。


八、图解:Fiber = 可恢复的调用栈

App
 ├── Header
 │    └── Logo
 ├── Content
 │    ├── List
 │    └── Detail
 └── Footer

Fiber 的结构类似于:

  • child 指向第一个子节点;
  • sibling 指向兄弟节点;
  • return 指回父节点。

这让 React 能像遍历树一样遍历组件,
并随时暂停、恢复任务,而不丢上下文。


九、总结:React 的“时间魔法”

如果你把 React 比作魔术师,那 Fiber 就是它的魔杖。
它让 React 拥有了“操控时间”的能力:

  • 可以让任务暂停,等浏览器忙完再继续;
  • 可以按优先级执行任务(比如用户输入优先于动画);
  • 可以在恢复时复用中间状态,不浪费计算。

🎯 一句话总结:

React Fiber = 代数效应 + 调度器 + 状态复用


【vue篇】Vue 初始化页面闪动(FOUC)问题终极解决方案

你是否遇到过这样的场景?

页面加载瞬间,用户看到满屏的 {{ message }}{{ user.name }} 等未编译的模板标签,几毫秒后才恢复正常。

这就是 Vue 初始化闪动问题,也称为 FOUC(Flash of Unstyled Content)。虽然时间短暂,但严重影响用户体验,尤其在弱网环境下更为明显。

本文将提供完整、可靠、可落地的解决方案,彻底根除此问题。


一、🔍 问题本质:Vue 初始化延迟

1. 问题原因

  • 浏览器先解析 HTML,此时 Vue 尚未加载或挂载;
  • 未编译的模板被直接渲染{{ }} 插值表达式暴露给用户;
  • Vue 实例初始化完成后,才接管 DOM,编译模板,替换内容。
<!-- 加载过程中用户看到的 -->
<div id="app">
  <h1>{{ title }}</h1>
  <p>{{ message }}</p>
</div>

<!-- Vue 初始化后 -->
<div id="app">
  <h1>我的应用</h1>
  <p>欢迎使用!</p>
</div>

二、✅ 官方推荐方案:v-cloak 指令

1. 基本用法

v-cloak 是 Vue 内置指令,在 Vue 实例编译完成前保留在元素上,编译完成后自动移除。

/* CSS 中隐藏带 v-cloak 的元素 */
[v-cloak] {
  display: none !important;
}
<div id="app" v-cloak>
  <h1>{{ title }}</h1>
  <p>{{ message }}</p>
</div>

✅ Vue 编译完成后,v-cloak 属性被移除,元素正常显示。


2. 增强版 CSS(防止被覆盖)

[v-cloak] {
  display: none;
}

/* 防止其他样式覆盖 */
[v-cloak]:before {
  content: '';
  display: block;
  /* 可选:显示加载动画 */
}

三、⚡ 进阶方案:确保根元素始终隐藏

有时 v-cloak 因 CSS 加载延迟而失效。此时需在 HTML 层面强制隐藏

方案 A:内联样式 + v-cloak

<div id="app"
     style="display: none;"
     v-cloak
     :style="{ display: 'block' }">
  <!-- 页面内容 -->
</div>

style="display: none;" 立即生效; ✅ :style="{ display: 'block' }" 在 Vue 初始化后覆盖内联样式。


方案 B:CSS 类 + 动态切换

.app-hidden {
  display: none !important;
}
<div id="app" class="app-hidden" :class="{ 'app-hidden': false }">
  <!-- 内容 -->
</div>

✅ 初始隐藏,Vue 启动后移除类。


四、🚀 最佳实践:多层防护策略

为确保 100% 消除闪动,建议组合使用多种方案

1. HTML + CSS + Vue 组合拳

<!DOCTYPE html>
<html>
<head>
  <style>
    /* 第一层:v-cloak */
    [v-cloak] {
      display: none;
    }
    
    /* 第二层:根容器预隐藏 */
    #app-container {
      opacity: 0;
      transition: opacity 0.3s;
    }
  </style>
</head>
<body>
  <!-- 第三层:内联样式强制隐藏 -->
  <div id="app-container" style="display: none;">
    <div id="app" v-cloak :style="{ opacity: 1 }">
      <h1>{{ title }}</h1>
    </div>
  </div>

  <script src="/vue.js"></script>
  <script>
    new Vue({
      el: '#app',
      data: { title: 'Hello Vue' },
      mounted() {
        // 第四层:确保容器显示
        document.getElementById('app-container').style.display = 'block';
      }
    });
  </script>
</body>
</html>

2. 配合骨架屏(Skeleton Screen)

<div id="app-container" style="display: none;">
  <!-- 骨架屏 -->
  <div class="skeleton">
    <div class="skeleton-header"></div>
    <div class="skeleton-content"></div>
  </div>

  <!-- 实际内容 -->
  <div id="app" v-cloak :style="{ display: 'block' }">
    <h1>{{ title }}</h1>
  </div>
</div>

✅ 用户看到的是优雅的骨架屏,而非乱码。


五、🔧 其他优化建议

1. 减少首屏加载时间

  • ✅ 使用 Vue CLI / Vite 优化构建;
  • ✅ 启用 Gzip 压缩
  • ✅ 使用 CDN 加载 Vue
  • ✅ 代码分割 + 懒加载。

2. 服务端渲染(SSR)

  • ✅ 使用 Nuxt.jsVue SSR
  • ✅ 页面直出 HTML,无闪动问题;
  • ✅ SEO 友好,首屏性能极佳。

六、❌ 常见误区

误区 正解
v-cloak 一定能解决” 需确保 CSS 优先加载
“只用内联样式就行” 建议结合 v-cloak 更可靠
“升级 Vue 就没了” 问题本质未变,仍需处理

💡 结语

“闪动虽短,体验至重——细节决定专业度。”

✅ 推荐解决方案优先级:

  1. 简单项目[v-cloak] { display: none; }
  2. 中大型项目v-cloak + 内联样式 + 骨架屏
  3. 高性能要求:SSR(Nuxt.js)

最终代码模板:

<div id="app"
     style="display: none;"
     v-cloak
     :style="{ display: 'block' }">
  {{ message }}
</div>

<style>
[v-cloak] { display: none; }
</style>

彻底告别 Vue 闪动问题,给用户丝滑流畅的首屏体验!

【vue篇】技术分析:Template 与 JSX 的本质区别与选型指南

在现代前端框架(如 Vue、React)中,TemplateJSX 是定义组件 UI 的两种主流方式。它们看似对立,实则殊途同归。

“该用 Template 还是 JSX?” “哪个更适合复杂组件?” “性能有差异吗?”

本文将从编译原理、开发体验、灵活性、维护性四个维度,深入剖析两者的本质区别,助你做出最佳技术选型。


一、🎯 核心结论:殊途同归

Template 和 JSX 都是 render 函数的语法糖

  • 运行时:框架最终只认 render 函数,它返回虚拟 DOM(VNode)。
  • 构建时
    • Template → 通过 vue-template-compiler 预编译为 render 函数。
    • JSX → 通过 Babel(如 @babel/plugin-transform-jsxbabel-plugin-transform-vue-jsx)转换为 h() 函数调用,即 render 函数。
// JSX 写法(Vue)
render() {
  return <div class="box">{this.message}</div>;
}

// 等价的 render 函数
render(h) {
  return h('div', { class: 'box' }, this.message);
}

// Template 写法(.vue 文件)
<template>
  <div class="box">{{ message }}</div>
</template>

// 编译后等价的 render 函数(与 JSX 相同)
render(h) {
  return h('div', { class: 'box' }, this.message);
}

✅ 最终生成的 VNode 结构完全一致,运行时性能无差异


二、⚙️ 编译机制对比

特性 Template JSX
编译工具 vue-template-compiler (Vue) / Angular Compiler Babel (@babel/plugin-transform-jsx)
编译时机 构建时(webpack/vite) 构建时
依赖 .vue 单文件组件 Babel 配置 + JSX 插件
错误提示 模板解析错误,定位较精确 JavaScript 语法错误,堆栈清晰

三、🧩 灵活性对比:JSX 胜出

1. JSX 的灵活性优势

  • 完整的 JavaScript 能力
    • 可直接使用 if/elsefor、三元表达式、函数调用。
    • 支持高阶组件(HOC)、Render Props 等高级模式。
// JSX:直接使用 JS 逻辑
render() {
  const items = this.list.map(item => (
    <li key={item.id}>
      {item.active ? <strong>{item.name}</strong> : item.name}
    </li>
  ));

  return (
    <ul>
      {this.loading ? <Loading /> : items}
    </ul>
  );
}
  • 动态组件与插槽更灵活
    • 可动态决定渲染哪个组件。
// JSX:动态组件
const Component = this.isModal ? Modal : Dialog;
return <Component {...this.props} />;

2. Template 的局限性

  • 受限的 JavaScript 表达式
    • Vue 模板中仅支持单个表达式,不能写 if/for 语句。
    • 复杂逻辑需抽离到 computedmethods
<!-- Template:逻辑必须分离 -->
<template>
  <ul>
    <li v-for="item in list" :key="item.id">
      <span v-if="item.active"><strong>{{ item.name }}</strong></span>
      <span v-else>{{ item.name }}</span>
    </li>
  </ul>
</template>

<script>
export default {
  computed: {
    renderedList() {
      return this.list.map(item => /* 复杂处理 */);
    }
  }
}
</script>

复杂组件中,JSX 更具优势,避免模板臃肿。


四、👁️ 开发体验对比:Template 更直观

1. Template 的优势

  • HTML-like 语法

    • 对设计师、初级开发者更友好;
    • 结构清晰,易于扫描。
  • 视图与逻辑分离

    • .vue 文件天然分隔 <template><script><style>
    • 强制关注点分离,提升可维护性。
  • 工具支持好

    • IDE 对 HTML 标签、属性有良好提示;
    • Vue DevTools 可直接查看模板结构。

2. JSX 的挑战

  • 学习成本

    • 需理解 JSX 编译原理;
    • classclassNameforhtmlFor 等命名差异。
  • 混合感强

    • JS 与 HTML 混合,可能破坏代码整洁性;
    • 过长的 JSX 可能难以阅读。
// JSX:过长时可能混乱
render() {
  return (
    <div>
      {this.user ? (
        <div>
          <h1>{this.user.name}</h1>
          {this.user.posts.map(post => (
            <div key={post.id}>
              <h2>{post.title}</h2>
              <p>{post.content}</p>
            </div>
          ))}
        </div>
      ) : null}
    </div>
  );
}

五、🚀 性能与优化

方面 Template JSX
运行时性能 ⚡ 相同(生成相同 VNode) ⚡ 相同
构建性能 🔧 依赖 vue-loader,可能稍慢 🔧 Babel 编译快
Tree-shaking ✅ 支持 ✅ 支持
SSR 支持 ✅ 原生支持 ✅ 支持

性能无本质差异,选择应基于开发体验。


六、🛠️ 适用场景推荐

场景 推荐方案 理由
企业级后台系统 ✅ Template 结构清晰,团队协作友好
复杂交互组件 ✅ JSX 逻辑复杂,JSX 更灵活
设计系统/组件库 ✅ JSX 支持 Render Props、HOC 等模式
快速原型开发 ✅ Template 上手快,HTML 感强
React 项目 ✅ JSX React 原生支持,生态完善
Vue 项目 ⚖️ 视团队而定 Vue 同时支持两者

七、🔧 Vue 中如何启用 JSX?

npm install @vitejs/plugin-vue-jsx -D
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

export default defineConfig({
  plugins: [
    vue(),
    vueJsx() // 启用 JSX 支持
  ]
})
// MyComponent.jsx
import { defineComponent } from 'vue'

export default defineComponent({
  props: ['msg'],
  render() {
    return <div class="hello">{this.msg}</div>
  }
})

💡 结语

“Template 是‘声明式画布’,JSX 是‘编程式画笔’。”

维度 Template JSX
灵活性 ⭐⭐☆ ⭐⭐⭐⭐⭐
直观性 ⭐⭐⭐⭐⭐ ⭐⭐⭐
维护性 ⭐⭐⭐⭐ ⭐⭐⭐
适用场景 简单/中等复杂度组件 复杂/动态组件

✅ 最佳实践建议:

  1. Vue 项目:优先使用 Template,复杂组件可局部使用 JSX;
  2. React 项目:直接使用 JSX,无需犹豫;
  3. 团队协作:统一风格,避免混用;
  4. 性能优化:关注 render 函数生成的 VNode,而非语法形式。

选择 Template 还是 JSX,本质是开发范式的选择。理解其背后原理,才能在项目中游刃有余。

【vue篇】SPA 单页面应用:现代 Web 的革命与挑战

你是否经历过这样的场景?

“点击导航,页面白屏2秒,用户体验极差!” “搜索引擎搜不到我的内容,流量上不去!” “前后端耦合严重,开发效率低!”

这些问题,正是 SPA(Single-Page Application,单页面应用) 在带来革命性体验的同时,也带来的“甜蜜的烦恼”。

本文将带你全面理解 SPA 的核心机制显著优势固有缺陷,并提供现代解决方案


一、什么是 SPA?

SPA 是一种 Web 应用架构,它只在初始加载时请求一次 HTML、CSS 和 JavaScript,之后所有的页面切换和内容更新都通过 JavaScript 动态完成,无需重新加载整个页面。

🎯 核心特征

  • 单页加载:首次加载所有资源;
  • 前端路由:通过 vue-routerreact-router 实现页面切换;
  • 局部更新:仅更新 DOM 的某一部分;
  • 前后端分离:前端负责 UI,后端提供 API。

二、SPA 工作原理:从 URL 变化到内容更新

用户访问 https://app.com/dashboard
          ↓
浏览器加载 index.html + main.js + vendor.css
          ↓
Vue/React 应用启动
          ↓
根据路由 /dashboard 渲染 Dashboard 组件
          ↓
用户点击“设置” → 路由跳转到 /settings
          ↓
JavaScript 拦截跳转,动态加载 Settings 组件
          ↓
局部更新 DOM,URL 变更为 /settings
          ↓
无刷新,页面切换完成

🔍 前端路由如何工作?

// vue-router 示例
const router = new VueRouter({
  mode: 'history', // 或 'hash'
  routes: [
    { path: '/dashboard', component: Dashboard },
    { path: '/settings', component: Settings }
  ]
});

// 当用户点击 <router-link to="/settings">
// Vue Router 拦截事件,调用 history.pushState()
// 触发组件更新,而非页面跳转

三、SPA 的三大核心优势

✅ 1. 极致的用户体验(UX)

指标 SPA 多页应用(MPA)
页面切换速度 ⚡ 毫秒级(局部更新) 🐢 秒级(整页刷新)
动画流畅度 ✅ 支持复杂交互动画 ❌ 刷新打断动画
用户感知 “像原生 App” “像传统网站”

💥 案例:Gmail、Figma、Notion 等均采用 SPA,提供“桌面级”体验。


✅ 2. 减轻服务器压力

  • 减少 HTTP 请求:无需每次跳转都请求 HTML;
  • 降低带宽消耗:后续切换仅传输 JSON 数据;
  • 后端专注 API:服务器只需提供 RESTful/GraphQL 接口。

📊 统计:SPA 的服务器请求数比 MPA 减少 60%+


✅ 3. 前后端分离,职责清晰

        +---------------------+
        |      前端团队       |
        |  Vue/React + 路由   |
        |  状态管理 + UI 交互 |
        +----------+----------+
                   |
                   | API 调用 (HTTP)
                   |
        +----------v----------+
        |      后端团队       |
        |  Node.js/Java/Go   |
        |  数据库 + 业务逻辑  |
        +---------------------+
  • ✅ 前端:专注用户体验;
  • ✅ 后端:专注数据处理和安全性;
  • ✅ 并行开发,提升效率。

四、SPA 的三大致命缺点

❌ 1. 首次加载慢(Initial Load Time)

问题 原因
⏳ 白屏时间长 需下载整个应用的 JS/CSS
📦 包体积大 包含所有路由组件代码
📉 用户流失 3秒未响应,50%用户离开

💡 数据:首屏加载每增加 1 秒,转化率下降 7%

✅ 解决方案:

  • 代码分割(Code Splitting):路由懒加载;
  • Tree Shaking:移除未使用代码;
  • CDN 加速:静态资源分发;
  • 骨架屏(Skeleton):提升感知性能。

❌ 2. 前进/后退路由管理复杂

  • 浏览器原生前进后退失效?不,SPA 通过 history.pushState()popstate 事件模拟;
  • 但问题在于
    • 路由状态需手动管理;
    • 某些场景(如表单未保存)需拦截跳转;
    • 嵌套路由、动态路由处理复杂。

✅ 解决方案:

// 拦截路由跳转
router.beforeEach((to, from, next) => {
  if (unsavedForm && !confirm('有未保存的内容,确定离开?')) {
    return next(false);
  }
  next();
});

💡 现代路由库(如 vue-router)已很好地解决了大部分问题。


❌ 3. SEO 友好性差

问题 原因
🕷️ 搜索引擎抓不到内容 初始 HTML 为空或只有 <div id="app"></div>
🔍 内容索引失败 JS 执行前无内容
📉 流量损失 自然搜索流量减少 80%+

⚠️ Google 可执行 JS,但效率低、成本高,且其他搜索引擎(Bing、百度)支持更差。

✅ 解决方案:

方案 适用场景
SSR(服务端渲染) 内容型网站(电商、博客)
预渲染(Prerendering) 静态页面(官网、文档)
动态渲染(Dynamic Rendering) 混合内容(Google 推荐)

五、SPA vs MPA vs SSR 对比

特性 SPA MPA SSR
首屏速度 ❌ 慢 ✅ 快 ✅ 快
交互体验 ✅ 极佳 ❌ 差 ✅ 好
SEO ❌ 差 ✅ 好 ✅ 好
开发复杂度 ⚠️ 中等 ✅ 简单 ❌ 复杂
适用场景 后台系统、Web App 传统网站 内容平台

六、现代 SPA 的最佳实践

✅ 1. 使用路由懒加载

{
  path: '/report',
  component: () => import('@/views/Report.vue') // 动态导入
}

✅ 2. 实现骨架屏

<div v-if="loading">
  <skeleton-loader />
</div>
<div v-else>
  <actual-content />
</div>

✅ 3. 结合 SSR/SSG 提升 SEO

  • 使用 Nuxt.jsNext.js
  • 构建时生成静态页(SSG);
  • 关键页面服务端渲染。

✅ 4. 启用 PWA

// vue.config.js
pwa: {
  manifestOptions: { start_url: '/' }
}
  • 支持离线访问;
  • 可安装到桌面。

💡 结语

“SPA 不是万能的,但它是构建现代 Web 应用的最佳起点。”

选择 推荐使用
SPA 后台系统、Web App、内部工具
SSR/SSG 博客、电商、营销页
MPA 简单静态站、SEO 优先的旧项目

🚀 SPA 成功关键:

  1. 优化首屏加载(懒加载 + CDN);
  2. 解决 SEO 问题(SSR/预渲染);
  3. 提升用户体验(骨架屏 + PWA)。

掌握 SPA 的“利”与“弊”,你就能构建出既智能的下一代 Web 应用。

【vue篇】Vue 性能优化全景图:从编码到部署的优化策略

在 Vue 项目中,你是否遇到过这些问题?

“页面加载太慢,用户流失严重!” “列表滚动卡顿,用户体验差!” “打包后 JS 文件 5MB,首屏 5 秒才出来!”

本文将系统性地为你梳理 Vue 性能优化的四大维度编码优化、SEO 优化、打包优化、用户体验优化,并提供可落地的实战方案


一、🎯 编码阶段优化:从源头提升性能

✅ 1. 减少 data 中的响应式数据

// ❌ 错误:将所有数据放入 data
data() {
  return {
    userList: [],      // 响应式
    config: {},        // 响应式
    utils: { /* 工具函数 */ } // ❌ 不需要响应式
  };
}

// ✅ 正确:非响应式数据挂载到实例
created() {
  this.utils = { /* 工具函数 */ };
  this.cache = new Map(); // 缓存
}

💡 原理data 中的每个属性都会被 Object.defineProperty 劫持,增加 getter/setterwatcher,影响性能。


✅ 2. v-ifv-for 禁止连用

<!-- ❌ 错误:先 v-for 再 v-if -->
<li v-for="user in users" v-if="user.active">
  {{ user.name }}
</li>

<!-- ✅ 正确:先过滤 -->
<li v-for="user in activeUsers">
  {{ user.name }}
</li>

<script>
computed: {
  activeUsers() {
    return this.users.filter(u => u.active);
  }
}
</script>

⚠️ v-for 优先级高于 v-if,会导致遍历所有项再过滤,性能浪费。


✅ 3. 事件代理:避免重复绑定

<!-- ❌ 错误:每个按钮绑定事件 -->
<ul>
  <li v-for="item in list" :key="item.id">
    <button @click="handleClick(item.id)">操作</button>
  </li>
</ul>

<!-- ✅ 正确:事件代理 -->
<ul @click="handleClick">
  <li v-for="item in list" :key="item.id">
    <button :data-id="item.id">操作</button>
  </li>
</ul>

<script>
methods: {
  handleClick(e) {
    const id = e.target.dataset.id;
    if (id) this.doSomething(id);
  }
}
</script>

💥 减少 n 个事件监听器,提升内存性能。


✅ 4. keep-alive 缓存组件

<keep-alive>
  <component :is="currentTab" />
</keep-alive>

<!-- 或 -->
<keep-alive include="UserDetail,OrderList">
  <router-view />
</keep-alive>
  • ✅ 避免组件重复创建/销毁;
  • ✅ 保留组件状态(如表单、滚动位置);
  • ✅ 适合频繁切换的 tabs

✅ 5. v-if vs v-show:按需选择

场景 推荐指令
条件很少改变 v-if(惰性渲染)
频繁切换显示/隐藏 v-show(仅切换 display
初始不显示,后期可能显示 v-if(避免初始渲染)

💡 v-if 切换开销大,v-show 初始渲染开销大。


✅ 6. key 保证唯一且稳定

<!-- ❌ 错误:使用 index 作为 key -->
<li v-for="(item, index) in items" :key="index">
  {{ item.name }}
</li>

<!-- ✅ 正确:使用唯一 ID -->
<li v-for="item in items" :key="item.id">
  {{ item.name }}
</li>

⚠️ index 会导致 Vue 无法正确复用节点,引发状态错乱


✅ 7. 路由懒加载 & 异步组件

// 路由懒加载
const routes = [
  {
    path: '/user',
    component: () => import('@/views/User.vue') // 动态导入
  }
];

// 异步组件
const AsyncComponent = () => import('./AsyncComponent.vue');
  • ✅ 减少首屏包体积;
  • ✅ 实现代码分割(Code Splitting)。

✅ 8. 防抖(Debounce)与节流(Throttle)

import { debounce } from 'lodash';

// 搜索框防抖
methods: {
  onSearch: debounce(function(query) {
    this.fetchResults(query);
  }, 300)
}

// 滚动节流
mounted() {
  window.addEventListener('scroll', throttle(this.handleScroll, 100));
}

💥 避免高频事件(搜索、滚动、窗口 resize)导致性能瓶颈。


✅ 9. 第三方库按需导入

// ❌ 全量导入
import _ from 'lodash';
import 'element-ui/lib/theme-chalk/index.css';

// ✅ 按需导入
import { debounce, throttle } from 'lodash-es';
import { ElButton, ElInput } from 'element-plus';

📦 使用 babel-plugin-componentunplugin-vue-components 自动按需导入。


✅ 10. 长列表优化:虚拟滚动

<virtual-scroll :items="hugeList" item-height="50">
  <template #default="{ item }">
    <div>{{ item.name }}</div>
  </template>
</virtual-scroll>
  • ✅ 只渲染可视区域的元素;
  • ✅ 支持万级数据流畅滚动;
  • ✅ 推荐库:vue-virtual-scrollertov-virtual-list

✅ 11. 图片懒加载

<img v-lazy="imageUrl" alt="lazy image" />

<!-- 或 -->
<IntersectionObserver @enter="loadImage" />
  • ✅ 延迟加载非首屏图片;
  • ✅ 减少首屏请求和流量消耗。

二、🔍 SEO 优化:让搜索引擎爱上你的 Vue 应用

✅ 1. 预渲染(Prerendering)

# 使用 prerender-spa-plugin
new PrerenderSPAPlugin({
  staticDir: path.join(__dirname, 'dist'),
  routes: ['/', '/about', '/contact']
})
  • ✅ 构建时生成静态 HTML;
  • ✅ 适合内容不频繁变化的页面;
  • ✅ 比 SSR 更轻量。

✅ 2. 服务端渲染(SSR)

// Nuxt.js / Vue SSR
server.get('*', (req, res) => {
  renderer.renderToString(app).then(html => {
    res.send(html);
  });
});
  • ✅ 实时生成 HTML,支持动态内容;
  • ✅ 首屏快,SEO 友好;
  • ✅ 适合电商、博客、新闻站。

三、📦 打包优化:让 JS 包更小、更快

✅ 1. 代码压缩

// webpack.config.js
optimization: {
  minimize: true,
  minimizer: [
    new TerserPlugin({ terserOptions: { compress: {} } })
  ]
}
  • ✅ 启用 TerserPlugin 压缩 JS;
  • css-minimizer-webpack-plugin 压缩 CSS。

✅ 2. Tree Shaking & Scope Hoisting

// webpack 4+
optimization: {
  usedExports: true,      // 标记未使用代码
  concatenateModules: true // Scope Hoisting
}
  • ✅ 移除未引用的代码(Dead Code);
  • ✅ 将模块合并为一个函数,提升执行速度。

✅ 3. CDN 加速第三方库

<!-- index.html -->
<script src="https://cdn.jsdelivr.net/npm/vue@3"></script>
// webpack.config.js
externals: {
  vue: 'Vue',
  'vue-router': 'VueRouter'
}
  • ✅ 利用 CDN 缓存;
  • ✅ 减少打包体积;
  • ✅ 提升加载速度。

✅ 4. 多线程打包(HappyPack 已淘汰,推荐 thread-loader

module: {
  rules: [
    {
      test: /\.js$/,
      use: [
        'thread-loader',
        'babel-loader'
      ]
    }
  ]
}

⚠️ Webpack 5 内置持久化缓存,thread-loader 效果有限。


✅ 5. splitChunks 抽离公共代码

optimization: {
  splitChunks: {
    chunks: 'all',
    cacheGroups: {
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        name: 'vendors',
        chunks: 'all'
      }
    }
  }
}
  • ✅ 将 node_modules 单独打包;
  • ✅ 利用浏览器缓存,提升二次访问速度。

✅ 6. sourceMap 优化

// 生产环境
devtool: 'hidden-source-map'; // 或 false

// 开发环境
devtool: 'eval-cheap-module-source-map';
  • ✅ 生产环境避免暴露源码;
  • ✅ 开发环境选择快速生成的 sourceMap。

四、🎨 用户体验优化:让用户“感觉”更快

✅ 1. 骨架屏(Skeleton Screen)

<div v-if="loading">
  <skeleton-card />
</div>
<div v-else>
  <actual-content />
</div>
  • ✅ 减少“白屏”感知;
  • ✅ 提升用户等待耐心。

✅ 2. PWA(渐进式 Web 应用)

// vue.config.js
pwa: {
  workboxOptions: {
    skipWaiting: true,
    clientsClaim: true
  }
}
  • ✅ 离线访问;
  • ✅ 快速加载(缓存资源);
  • ✅ 可添加到主屏幕。

✅ 3. 缓存策略

类型 方案
客户端缓存 localStorageIndexedDBService Worker
服务端缓存 Redis 缓存 API 响应、HTML 页面
CDN 缓存 静态资源缓存

✅ 4. Gzip 压缩

# Nginx 配置
gzip on;
gzip_types text/css application/javascript;
  • ✅ 通常可压缩 70% 体积;
  • ✅ 必须开启!

五、性能优化决策树

你的应用是否需要 SEO?
├── 是 → 考虑 SSR 或 预渲染
└── 否 → 进入下一步

首屏加载是否慢?
├── 是 → 路由懒加载、代码分割、CDN、Gzip
└── 否 → 进入下一步

是否有长列表?
├── 是 → 虚拟滚动
└── 否 → 进入下一步

交互是否卡顿?
├── 是 → 防抖/节流、事件代理、减少响应式数据
└── 否 → 你已经很优秀了!

💡 结语

“性能优化不是一次性任务,而是贯穿开发全流程的思维方式。”

优化维度 关键策略
编码 keep-alivev-if、事件代理、防抖
SEO SSR、预渲染
打包 懒加载、CDN、splitChunks、压缩
体验 骨架屏、PWA、缓存、Gzip

掌握这些优化技巧,你就能:

✅ 构建出秒开的 Vue 应用;
✅ 提升用户留存率转化率
✅ 让产品在竞争中脱颖而出。

【vue篇】SSR 深度解析:服务端渲染的“利”与“弊”

在构建现代 Web 应用时,你是否遇到过这些痛点?

“单页应用(SPA)SEO 不友好,搜索引擎抓不到内容!” “首屏加载白屏太久,用户体验差!” “如何让 Vue 应用既快又利于 SEO?”

答案就是:SSR(Server-Side Rendering,服务端渲染)

本文将全面解析 SSR 的核心原理优势与挑战,以及适用场景


一、什么是 SSR?

SSR 是指在服务器端将 Vue 组件渲染成 HTML 字符串,再将完整的 HTML 页面直接返回给浏览器。

🎯 传统 SPA vs SSR

模式 渲染流程 用户体验
SPA(客户端渲染) 1. 下载空 HTML
2. 加载 JS
3. JS 执行,生成 DOM
4. 渲染页面
❌ 首屏慢,SEO 差
SSR(服务端渲染) 1. 服务器渲染 HTML
2. 返回完整 HTML
3. 浏览器直接显示
4. JS 下载后“激活”交互
✅ 首屏快,SEO 友好

二、SSR 工作原理:从 Vue 组件到 HTML

用户请求 → Node.js 服务器
               ↓
       Vue 组件 + 数据
               ↓
   Vue 服务器渲染器(vue-server-renderer)
               ↓
   生成 HTML 字符串(带内联数据)
               ↓
   返回给浏览器(首屏已渲染)
               ↓
   浏览器下载 JS
               ↓
   Vue 客户端“激活”(hydrate)
               ↓
   页面具备交互能力

🔍 核心模块:vue-server-renderer

// server.js
import { createRenderer } from 'vue-server-renderer';
import app from './app.vue';

const renderer = createRenderer();

server.get('*', async (req, res) => {
  const context = { url: req.url };
  try {
    // 将 Vue 实例渲染为 HTML
    const html = await renderer.renderToString(app, context);
    res.send(`
      <!DOCTYPE html>
      <html>
        <body>${html}</body>
        <script src="/client-bundle.js"></script>
      </html>
    `);
  } catch (err) {
    res.status(500).send('Render Error');
  }
});

💡 renderToString() 是 SSR 的核心 API。


三、SSR 的三大核心优势

✅ 1. 更好的 SEO(搜索引擎优化)

  • 搜索引擎爬虫 直接看到完整 HTML 内容;
  • 无需等待 JS 执行,内容可索引
  • 适合内容型网站:博客、电商商品页、文档站。

📈 案例:某电商网站启用 SSR 后,自然搜索流量提升 40%


✅ 2. 更快的首屏加载速度

指标 SPA SSR
FCP(首次内容绘制) 2s+ < 1s
用户感知性能 “白屏等待” “秒开”

💥 SSR 避免了“下载 JS → 解析 → 执行 → 渲染”的漫长链路。


✅ 3. 更好的弱网用户体验

  • 在 2G/3G 网络下,用户能先看到内容
  • 即使 JS 加载失败,页面内容依然可读;
  • 提升用户留存率

四、SSR 的三大挑战与限制

❌ 1. 生命周期钩子受限

在服务端渲染时,只有 beforeCreatecreated 钩子会被调用。

export default {
  beforeCreate() {
    // ✅ 服务端和客户端都会执行
    console.log('beforeCreate');
  },
  created() {
    // ✅ 服务端和客户端都会执行
    this.fetchData();
  },
  mounted() {
    // ❌ 只在客户端执行
    // document, window, event listeners
  },
  beforeMount() {
    // ❌ 只在客户端执行
  }
}

⚠️ 不能在 created 之前访问 documentwindow 等浏览器 API


❌ 2. 第三方库兼容性问题

许多前端库依赖浏览器环境:

// ❌ 错误:服务端没有 window 对象
import someLib from 'some-browser-lib';

// ✅ 正确:动态导入,客户端才执行
if (typeof window !== 'undefined') {
  import('some-browser-lib').then(lib => {
    // 初始化
  });
}

常见问题库:

  • localStorage / sessionStorage
  • document.querySelector
  • window.addEventListener
  • 图表库(如 ECharts、D3)
  • 动画库(如 GSAP)

❌ 3. 更高的服务端负载

  • 每次请求都需要 执行 JavaScript 渲染
  • 占用 CPU 和内存资源;
  • 高并发时可能成为性能瓶颈。

💡 解决方案:

  • 缓存:对静态页面进行 HTML 缓存;
  • CDN 预渲染:提前生成静态页;
  • 流式渲染renderToStream() 逐步输出。

五、SSR 适用场景

场景 是否推荐 SSR
内容型网站(博客、新闻、电商) ✅ 强烈推荐
后台管理系统 ❌ 不推荐(无需 SEO)
内部工具 ❌ 不推荐
营销落地页 ✅ 推荐(追求首屏速度)
实时聊天应用 ❌ 不推荐(交互为主)

六、现代 SSR 解决方案

1. Nuxt.js(Vue 2/3)

  • 全功能 SSR 框架;
  • 文件路由、自动代码分割;
  • 支持静态生成(SSG)。
npx create-nuxt-app my-app

2. Vue 3 + Vite + Vue Server Renderer

  • 更快的开发体验;
  • 原生 ES 模块支持;
  • 适合定制化 SSR 应用。

3. Nitro(Nuxt 3 的引擎)

  • 支持多平台部署(Node、Serverless、Edge);
  • 极致性能优化。

七、SSR vs SSG vs CSR 对比

模式 全称 特点 适用场景
SSR 服务端渲染 请求时实时生成 HTML 动态内容,个性化页面
SSG 静态生成 构建时生成 HTML 文件 博客、文档、营销页
CSR 客户端渲染 浏览器生成 DOM 后台系统、Web App

💡 SSG 是 SSR 的“编译时”版本,性能更高。


💡 结语

“SSR 不是银弹,而是为特定场景而生的利器。”

选择 推荐使用
SSR 需要 SEO + 动态内容
SSG 内容相对静态,追求极致性能
CSR 无需 SEO,交互复杂的应用

🚀 SSR 最佳实践:

  1. ✅ 优先考虑 SSG(如 Nuxt generate);
  2. ✅ 合理使用 缓存 减轻服务端压力;
  3. ✅ 第三方库做 客户端动态导入
  4. ✅ 避免在 created 钩子中执行耗时同步操作

掌握 SSR,你就能构建出既利于 SEO 的现代 Web 应用。

NestJS入门(2)——数据库、用户、备忘录模块初始化

项目搭建完成后根据我们的需求目前可以将功能模块大致分为用户、备忘录、配置、数据库模块(使用MongoDB),本章我们就来学习初始化这四个模块。

技术点

模块

在Nest中使用 @Module() 装饰器注解的类我们称之为模块,一般一组密切相关的功能被封装为一个模块,方便管理应用。

可以使用 CLI 命令快捷创建模块,$ nest g module moduleName

在本需求中用户相关功能、备忘录相关功能、数据库链接我们就可以将其封装为不同的模块。

装饰器

上面模块的解释中我们提到了装饰器,这个在前端领域中还属于一种较新的概念。

ES2016中装饰器是一个返回函数的表达式。使用时需要添加 @ 作为前缀,它可以接收目标对象、名称和属性描述符作为参数,并返回经过扩展功能后的同名函数。

简单示例,有一个基本 User 类,我们可以通过一个装饰器来实现让它的某个属性只读:

function readonly (target, key, descriptor){
    descriptor.writable = false
    return descriptor
}

class User (){
    constructor(name){
        this.name = name
    }
    @readly
    hello(){
        return `${this.name} say Hello!`
    }
}

也可以通过一个装饰器实现对类的扩充:

function userAge (age){
    return function(target){
        target.age = age
    }
}

@userAge(18)
class User (){
    constructor(name){
        this.name = name
    }
}

Nest中我们如果自定义装饰器的需要访问上下文、支持依赖注入、类型安全与框架集成的话需要使用 createParamDecorator 方法进行包裹,比如我们定义一个装饰器从请求中获取用户信息

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const User = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.user;
  },
);

// 使用
@Get() 
async findOne(@User() user: UserEntity) { 
    console.log(user); 
}

@Module() 装饰器

@Module() 装饰器接收的参数是一个对象:

属性 描述
providers 将由 Nest 注入器实例化,且至少可在本模块内共享的提供者
controllers 本模块中定义的需要实例化的控制器集合
imports 导入模块的列表,这些模块导出了本模块所需的提供者
exports 本模块提供的 providers 子集,这些提供者应可供导入本模块的其他模块使用。可以使用提供者本身或其令牌(provide 值)

共享模块

在 Nest 中模块默认都是单例,在整个应用程序生命周期中每个模块只会被实例化一次,这样做可以的好处:

  • 资源高效性。避免重复创建实例,减少内存开销。
  • 状态一致性
  • 依赖安全。避免出现多个实例导致的依赖关系错乱。

通过在模块的 exports 数组中导出实例,模块一旦创建就可以自动成为共享模块,可以在多个模块中引入共享该模块的实例。

全局模块

如果需要在多个地方导入相同的模块集,重复 imports 就会显得繁琐,这时可以通过使用 @Global() 装饰器将模块设置为全局模块。

import { Module, Global } from '@nestjs/common'; 
import { UserController } from './user.controller'; 
import { UserService } from './user.service'; 

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

@Global() 装饰器使模块具有全局作用域,全局模块由根模块或核心模块仅注册一次,需要注入该服务的模块也无需在 imports 数组中重复导入。

动态模块

动态模块允许创建可在运行时配置的模块,在需要灵活、根据配置返回定制化模块时特别有用(如数据库不同环境配置)。

动态模块需要提供一个静态方法(通常叫做 register 或者 forRoot)来返回一个符合 DynamicModule 接口的对象,使用时在 @Module() 装饰器的 imports 属性中调用。

  • register 通常表示该动态模块是在调用时使用,不同的调用配置可能不同。
  • forRoot 通常表示希望一次性配置动态模块,并在多个地方复用该配置。

代码实现

配置模块初始化

为了区分开发、生产环境,我们需要通过不同的配置文件来管理不同的环境配置。

在 Node.js 的应用程序中通常使用 .env 文件设置不同环境的配置。

一、创建环境配置文件

在项目根目录创建:

  • .env.development,对应开发环境配置
  • .env.production,对应生产环境配置

.env.development 文件内容:

# 项目端口
PORT=9527

.env.production 文件内容:

# 项目端口
PORT=9528

二、引入配置模块

Nest 提供了一个 @nestjs/config 包,可以通过它创建一个 ConfigModule,并暴露一个加载相应 .env 文件的 ConfigService

ConfigModule 就是之前提过的动态模块,允许我们通过传递参数定制其内部逻辑。

接下来我们通过该包去创建配置模块。

安装依赖:

$ npm i --save @nestjs/config

配置模块一般会在根模块 AppModule 引入,并使用静态方法 forRoot 来控制其行为,这个步骤中环境变量的键值对会被解析和处理。

import { Module } from '@nestjs/common'; 
import { ConfigModule } from '@nestjs/config'; 

@Module({ 
imports: [ConfigModule.forRoot({
    envFilePath: `.env.${process.env.NODE_ENV || 'development'}`
})], 
}) 

export class AppModule {}

上述配置会从项目根目录加载并解析对应环境的 .env 文件,将其中的键值对与分配给 process.env 的环境变量合并,并将结果存储在一个可通过 ConfigService 访问的私有结构中。

forRoot 方法会注册 ConfigService 提供者,该提供者提供了用于读取配置变量的 get 方法。

当某个变量同时存在于运行时环境变量和 .env 文件中时,运行时环境变量具有优先权。

配置配置一般会在很多其他模块中使用,可以通过将 forRoot 参数的 isGlobal 属性设置为 true,将其声明为全局模块。

ConfigModule.forRoot({
    envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
    isGlobal: true
})

当其他模块使用时就无需再次通过 @Module 导入,只需从 @nestjs/config 引入 ConfigService 即可使用相关数据及功能(见后续数据库模块初始化中示例)。

三、调整 main.ts 使用配置文件变量

目前配置存储在服务中,main.ts 如果要访问配置,必须通过 app.get 方法获取服务使用:

import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const configService = app.get(ConfigService);
  const port = configService.get('PORT');
  await app.listen(port ?? 3000);
}
bootstrap();

四、配置不同环境启动脚本

package.json 中配置不同环境的启动命令:

{
  "scripts": {
    "start": "NODE_ENV=production nest start",
    "start:dev": "NODE_ENV=development nest start --watch",
  }
}

数据库模块初始化

MongoDB 常用的连接器是 Mongoose,Nest 提供了针对它的专用包 @nestjs/mongoose

一、下载依赖

首先安装依赖:

$ npm i @nestjs/mongoose mongoose

二、环境变量增加数据库配置

设置不同环境的数据库配置,以开发环境举例:

# 项目端口
PORT=9527

# MongoDB 配置
DB_HOST=localhost # 数据库地址
DB_PORT=27017 # 数据库端口
DB_NAME=doraemonNotebook # 数据库名称
DB_USER=developer # 数据库账号
DB_PASS=developerPass # 数据库密码

三、引入数据库模块初始化全局连接

导入 MongooseModule 到根模块:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
    }),
    MongooseModule.forRootAsync({
      useFactory: (configService: ConfigService) => {
        return {
          uri: `mongodb://${configService.get('DB_USER')}:${configService.get('DB_PASS')}@${configService.get('DB_HOST')}:${configService.get('DB_PORT')}/${configService.get('DB_NAME')}`,
        };
      },
      inject: [ConfigService],
    }),
  ]
})

export class AppModule {}

MongooseModule 也为动态模块。

因为数据库的配置参数需要在 ConfigModule 中实例化 .env文件后才能配置到环境变量中,所以需要使用暴露出来的 forRootAsnyc 异步方法来引入数据库模块,其提供了全局的连接配置。

后续各个模块使用只需要通过 MongooseModule.forFeature 来关注自己的数据模型定义和使用。

用户模块初始化

一、创建模块

创建用户模块:

$ nest g module user

Nest 会自动在项目 src 目录下创建 user 文件夹及文件夹内的 user.module.ts 文件,同时并自动在 AppModule 中引入。

二、定义模型

根据领域就近原则我们在 user 目录下创建一个 schemas 目录及其下面的 user.schema.ts 文件,并定义以下内容:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';

export type UserDocument = HydratedDocument<User>;

@Schema({ timestamps: true }) // 自动添加 createdAt 和 updatedAt 字段
export class User {
  @Prop({ required: true })
  name: string;

  @Prop({ required: true, unique: true }) // 定义必填及唯一索引
  phone: number;

  @Prop({ required: true })
  password: string;
}

export const UserSchema = SchemaFactory.createForClass(User);

@Schema() 装饰器将类标记为模式定义。它将 User 类映射到同名的 MongoDB 集合,但会在末尾添加一个"s",Mongo 集合的名称将是 Users。它接受一个可选参数,同 mongoose.Schema 类构造函数的第二个参数,详情参考

@Prop() 装饰器定义文档属性。它也可以接受一个可选参数,同 mongoose.SchemaTypes 配置,详情参考

三、注册模型

更新 UserModule 模块,注册 User 模型:

// user.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { UserSchema, User } from './schemas/user.schema';

@Module({
  imports: [
    MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
  ],
})
export class UserModule {}

forFeature 配置数据库模块,告诉 Nest 定义名为 User 的模型,该模型基于 UserSchema 定义。

注意:这里 User.name 指的是 User 类的名称 'User',是类的静态属性,而不是使用 @Prop() 装饰的 'name' 实例属性。

备忘录模块初始化

一、创建模块

创建用户模块:

$ nest g module todo

二、定义模型

todo 目录下创建一个 schemas 目录及其下面的 todo.schema.ts 文件,并定义以下内容:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
import type { ObjectId } from 'mongoose';

export type TodoDocument = HydratedDocument<Todo>;

@Schema({ timestamps: true })
export class Todo {
  @Prop({ required: true })
  title: string;

  @Prop({ required: true })
  content: string;

  @Prop({ required: true, enum: ['pending', 'completed'], default: 'pending' })
  status: string;

  @Prop({ required: true })
  userId: ObjectId;
}

export const TodoSchema = SchemaFactory.createForClass(Todo);

三、注册模型

更新 TodoModule 模块,注册 Todo 模型:

// todo.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { TodoSchema, Todo } from './schemas/todo.schema';

@Module({
  imports: [
    MongooseModule.forFeature([{ name: Todo.name, schema: TodoSchema }]),
  ],
})
export class TodoModule {}

React 项目 SVG 图标太难管?用这套自动化方案一键搞定!

概述

在前端开发中,我们经常会用到各种 icon 图标

你通常是如何管理这些图标的呢?是从蓝湖或 Figma 下载 .png 文件直接放到项目中?还是从 Iconfont 拷贝 SVG 代码嵌入到页面里?相信大多数人已经很少再使用“雪碧图”这种老方法了。

对于需要根据状态切换颜色的图标,早期的常见做法是下载两张图片,分别表示默认与激活状态。但使用 SVG 后,只需在状态变化时切换 fill 或 stroke 属性即可,灵活又高效。

不过,当项目图标越来越多,直接将 SVG 代码嵌入到组件中会让代码臃肿、难以维护。那么,我们该如何更优雅地加载与管理 SVG 呢?

本文将带你实现一个自动化、类型安全、可配置的 SVG 管理方案。

本文主要包含以下几点:

  1. 项目准备
  2. 编写自动生成 SVG 类型定义的脚本
  3. 封装一个可复用的 SVG 加载组件

项目准备

本文基于 React + Vite + TypeScript 技术栈构建。

1)创建项目

$ pnpm create vite svg-examples --template react-ts
│
◇  Use rolldown-vite (Experimental)?:
│  No
│
◇  Install with pnpm and start now?
│  Yes
│
◇  Scaffolding project in /Users/leo/Desktop/svg-examples...
│
◇  Installing dependencies with pnpm...
$  cd svg-examples && code .

2)配置 tailwindcss

🔵 安装依赖

$ pnpm add tailwindcss @tailwindcss/vite

🔵 配置 Vite 插件:在 vite.config.ts 配置文件中添加 @tailwindcss/vite 插件

vite.config.ts

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
});

🔵 导入 Tailwind CSS

index.css

@import "tailwindcss";

:root {
  --text-primary-color: green;
}

提示:这里我定义了一个 css 变量 --text-primary-color,后续我们在尝试设置 svg 的颜色时会用到。

创建脚本

接下来我们编写一个脚本,用于自动扫描项目中的 SVG 文件并生成对应的 TypeScript 类型定义,这样我们在组件中使用图标时,就能享受智能提示和类型检查。

图标统一存放在 public/icons 目录下,脚本会自动遍历该目录、生成类型文件,并支持监听模式,自动更新。

在开始前,你可以先从 iconfont ↪ 下载一些 SVG 图标放到该目录中。

推荐按模块分类,比如我的目录结构如下:

.
├── icons
│   ├── profile
│   │   └── orders.svg
│   ├── tiktok.svg
│   └── wx.svg
└── vite.svg

脚本需要以下开发依赖:

$ pnpm add chokidar chalk prettier --save-dev

依赖解读:

  • chokidar:监听文件变化
  • chalk:命令行输出美化
  • prettier:格式化生成的代码

在 package.json 中添加脚本命令:

{
   "gen-svg": "npx tsx scripts/gen-svg-list.ts",
   "gen-svg-watch": "npx tsx scripts/gen-svg-list.ts --watch"
}

然后创建文件 scripts/gen-svg-list.ts,粘贴以下代码 👇

/**
 * 自动扫描 public/icons 下的所有 .svg 文件
 * 并生成 src/components/Icon/svgPath_all.ts
 * 支持 --watch 模式实时监听变动
 *
 * 用法:
 *   npx tsx scripts/gen-svg-list.ts          # 一次性生成
 *   npx tsx scripts/gen-svg-list.ts --watch  # 实时监听模式
 *
 *  "gen-svg": "npx tsx scripts/gen-svg-list.ts",
 *  "gen-svg-watch": "npx tsx scripts/gen-svg-list.ts --watch"
 *
 * 依赖:
 *   pnpm add chokidar chalk prettier --save-dev
 */
import fs from "node:fs/promises";
import fssync from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import chokidar, { FSWatcher } from "chokidar";
import prettier from "prettier";
import chalk from "chalk";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// ==================== 路径配置 ====================
/** 项目根目录 */
const projectRoot = path.resolve(__dirname, "..");
/** SVG 图标目录 */
const ICONS_DIR = path.join(projectRoot, "public", "icons");
/** 输出文件路径 */
const outputFile = path.join(projectRoot, "src", "components", "IconSvg/svgPath_all.ts");

// ==================== 工具函数 ====================
/**
 * 递归扫描指定目录下的所有 SVG 文件
 * @param dir 要扫描的目录路径
 * @returns 返回包含所有 SVG 文件完整路径的数组
 */
async function walkDir(dir: string): Promise<string[]> {
  const entries = await fs.readdir(dir, { withFileTypes: true });
  const results: string[] = [];

  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      results.push(...(await walkDir(fullPath)));
    } else if (entry.isFile() && fullPath.endsWith(".svg")) {
      results.push(fullPath);
    }
  }

  return results;
}

/**
 * 生成 svgPath_all.ts 文件
 * - 扫描 ICONS_DIR 下所有 SVG 文件
 * - 输出为 TypeScript const 数组及类型
 * - 使用 prettier 格式化
 * @param showLog 是否打印生成日志,默认为 true
 */
async function generate(showLog = true): Promise<void> {
  const svgFiles = (await walkDir(ICONS_DIR)).sort();

  const svgNames = svgFiles.map((fullPath) => {
    const relative = path.relative(ICONS_DIR, fullPath);
    const noExt = relative.replace(/\.svg$/i, "");
    return noExt.split(path.sep).join(path.posix.sep);
  });

  const timestamp = new Date().toISOString();
  const output = `
// ⚠️ 此文件由脚本自动生成,请勿手动修改
// 生成时间: ${timestamp}
export const SVG_PATH_NAMES = [
  ${svgNames.map((n) => `"${n}"`).join(",\n  ")}
] as const;

export type SvgPathName = typeof SVG_PATH_NAMES[number];
`;

  const prettierConfig = (await prettier.resolveConfig(projectRoot)) ?? {};
  const formatted = await prettier.format(output, { ...prettierConfig, parser: "typescript" });

  await fs.mkdir(path.dirname(outputFile), { recursive: true });
  await fs.writeFile(outputFile, formatted, "utf8");

  if (showLog) {
    console.log(chalk.green(`✔️ 已生成 ${chalk.yellow(outputFile)},共 ${svgNames.length} 个图标`));
  }
}

// ==================== 防抖函数 ====================
/**
 * 防抖函数
 * @template F 原始函数类型
 * @param fn 要防抖的函数
 * @param delay 防抖延迟(毫秒)
 * @returns 返回防抖后的函数
 */
function debounce<F extends (...args: unknown[]) => void>(fn: F, delay: number): F {
  let timer: NodeJS.Timeout | null = null;
  return ((...args: Parameters<F>) => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  }) as F;
}

// ==================== 监听逻辑 ====================
/**
 * 主函数
 * - 首次生成 svgPath_all.ts
 * - 可选择开启监听模式 (--watch) 实时生成
 */
async function main(): Promise<void> {
  await generate(true);

  if (process.argv.includes("--watch")) {
    console.log(chalk.cyan("👀 正在监听 SVG 目录变动..."));

    if (!fssync.existsSync(ICONS_DIR)) {
      console.log(chalk.red(`❌ 图标目录不存在: ${ICONS_DIR}`));
      process.exit(1);
    }

    const watcher: FSWatcher = chokidar.watch(ICONS_DIR, {
      ignoreInitial: true,
      depth: 10,
    });

    // 防抖生成函数,延迟 300ms
    const debouncedGenerate = debounce(() => generate(false), 300);

    /**
     * 文件变动事件处理
     * @param event 事件类型,例如 'add', 'unlink', 'change'
     * @param file 触发事件的文件完整路径
     */
    const onChange = (event: string, file: string) => {
      const fileName = path.relative(ICONS_DIR, file);
      console.log(chalk.gray(`[${event}]`), chalk.yellow(fileName));
      debouncedGenerate();
    };

    watcher
      .on("add", (file) => onChange("➕ 新增", file))
      .on("unlink", (file) => onChange("➖ 删除", file))
      .on("change", (file) => onChange("✏️ 修改", file))
      .on("error", (err) => console.error(chalk.red("监听错误:"), err));
  }
}

// ==================== 执行入口 ====================
main().catch((err) => {
  console.error(chalk.red("❌ 生成 svgPath_all.ts 失败:"), err);
  process.exit(1);
});

执行命令:

$ pnpm gen-svg

控制台输出如下:

✔️ 已生成 /your path/svg-examples/src/components/IconSvg/svgPath_all.ts,共 3 个图标

最后,我们看看生成的内容

/src/components/IconSvg/svgPath_all.ts

// ⚠️ 此文件由脚本自动生成,请勿手动修改
// 生成时间: 2025-10-13T19:38:41.919Z

export const SVG_PATH_NAMES = ["profile/orders", "tiktok", "wx"] as const;

export type SvgPathName = (typeof SVG_PATH_NAMES)[number];

这个文件主要是方便我们后续创建组件时用于定义 icon 名称,调用者在使用组件时也可以方便的选中对应的图标。

提示:如果不想手动执行脚本,可以使用 pnpm gen-svg-watch 开启监听模式,新增或删除图标后会自动更新类型定义。

创建组件

下面我们来封装一个高可用的 SVG 组件,具备以下特性:

  1. 动态加载 SVG 图标文件

    • 根据传入的 name 从 /public/icons 目录按需加载对应 .svg 文件。
    • 支持从本地缓存(svgCache)中读取,避免重复请求。
  2. SVG 内容安全清理

    • 自动移除 <script>、<foreignObject>、onClick 等危险标签与属性。
    • 去掉不必要的属性(如 width、height、version 等),保证安全可控。
  3. 智能尺寸处理

    • 自动识别是否存在 w-、h-、size- 等 Tailwind 尺寸类。
    • 若无显式尺寸,默认渲染为 16x16。
    • 若用户传入 size 或样式宽高,则自适应容器(width="100%" height="100%")。
  4. 颜色智能替换

    • 优先读取 props.color 或 style.fill / style.stroke。
    • 支持 TailwindCSS 的 fill-* 与 stroke-* 类名解析。
    • 自动为未定义颜色的路径加上 fill="currentColor",支持继承文本颜色。
  5. SVG 预处理与渲染

    • 整合清理、尺寸、颜色逻辑,生成最终可直接注入的安全 SVG 字符串。
    • 使用 dangerouslySetInnerHTML 安全地插入 SVG。
  6. 错误与占位处理

    • 若加载失败或找不到图标名,渲染 fallback(默认显示 ⚠)。
  7. 性能优化与防抖逻辑

    • 对已加载过的图标结果进行内存缓存(最多 200 个)。
    • 清理旧缓存,保证内存占用稳定。
  8. 完备的类型定义与可扩展性

    • 提供了 SvgPathTypes 类型自动推导(由生成脚本生成)。
    • 支持 wrapperClass、onClick 等常用交互属性。

话不多说,我直接贴上代码:

import React, { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { SVG_PATH_NAMES } from "./svgPath_all";

// ==================== 类型定义 ====================
export type SvgPathTypes = (typeof SVG_PATH_NAMES)[number];
export interface IconProps {
  /** SVG 文件名(不含后缀) */
  name: SvgPathTypes;
  /** 应用于 <svg> 容器 div 的类名(Tailwind 或自定义类) */
  className?: string;
  /** 图标主色,可为颜色值 / Tailwind 类名(fill-xxx / stroke-xxx)/ CSS 变量 */
  color?: string;
  /** 图标尺寸,可为数字或字符串(如 20 / '1.5rem') */
  size?: number | string;
  /** 内联样式 */
  style?: React.CSSProperties;
  /** 最外层 div 的类名 */
  wrapperClass?: string;
  /** 加载或解析异常时的占位符 */
  fallback?: React.ReactNode;
  /** 点击事件 */
  onClick?: () => void;
}

// ====================  缓存逻辑  ====================
const MAX_CACHE_SIZE = 200;
const svgCache = new Map<string, string>();
function cacheSet(key: string, value: string) {
  if (svgCache.has(key)) svgCache.delete(key);
  svgCache.set(key, value);
  if (svgCache.size > MAX_CACHE_SIZE) {
    const firstKey = svgCache.keys().next().value;
    if (typeof firstKey === "string") {
      svgCache.delete(firstKey);
    }
  }
}

// ====================  工具函数  ====================
/** 保留这些颜色(不替换为 currentColor) */
const preserveColors = ["none", "transparent", "inherit", "currentcolor"];
function shouldPreserve(color: string) {
  const c = (color || "").trim().toLowerCase();
  return c === "" || preserveColors.includes(c) || c.startsWith("url(");
}

/** 检查 className 是否包含尺寸类(w-, h-, size-, min/max-w/h-) */
function hasSizeClass(className?: string): boolean {
  if (!className) return false;
  return /\b(?:w|h|size|(?:min|max)-(?:w|h))-/.test(className);
}
/**
 * 清理 SVG:
 * - 去除危险标签与事件属性
 * - 去除 width/height/xml 声明
 * - 转换 JSX 兼容属性(如 class → className)
 */
function sanitizeSvg(svgText: string): string {
  if (!svgText) return "";

  return (
    svgText
      // 移除 script / foreignObject
      .replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
      .replace(/<foreignObject[\s\S]*?>[\s\S]*?<\/foreignObject>/gi, "")
      // 移除事件属性与 js 协议
      .replace(/\son\w+="[^"]*"/gi, "")
      .replace(/\son\w+='[^']*'/gi, "")
      .replace(/javascript:[^"']*/gi, "")
      .replace(/<!ENTITY[\s\S]*?>/gi, "")
      // 移除 XML 声明和 DOCTYPE
      .replace(/<\?xml[\s\S]*?\?>/gi, "")
      .replace(/<!DOCTYPE[\s\S]*?>/gi, "")
      // 去除 width / height / 其他无意义属性
      .replace(/\s+(width|height|t|p-id|version)\s*=\s*(["'][^"']*["']|\S+)/gi, "")
      // JSX 属性名转换
      .replace(/\bclass=/gi, "className=")
      .replace(/\bclip-rule=/gi, "clipRule=")
      .replace(/\bfill-rule=/gi, "fillRule=")
      .replace(/\bstroke-width=/gi, "strokeWidth=")
      .replace(/\bstroke-linecap=/gi, "strokeLinecap=")
      .replace(/\bstroke-linejoin=/gi, "strokeLinejoin=")
      // 清理多余空格
      .replace(/\s{2,}/g, " ")
      .trim()
  );
}

// ====================  颜色处理  ====================
/** 从 props 中提取 SVG 的 fill / stroke 颜色 */
function extractSvgColor({ color, className, style }: Pick<IconProps, "color" | "className" | "style">): {
  fill?: string;
  stroke?: string;
} {
  const result: { fill?: string; stroke?: string } = {};

  // 1️⃣ 优先使用显式 props
  if (style?.fill) result.fill = style.fill;
  if (style?.stroke) result.stroke = style.stroke;
  if (color) result.fill = color;

  // 2️⃣ TailwindCSS 类名解析
  if (className) {
    const fillMatch = className.match(/\bfill-([a-zA-Z0-9-_]+)/);
    const strokeMatch = className.match(/\bstroke-([a-zA-Z0-9-_]+)/);
    if (fillMatch) result.fill = `var(--${fillMatch[0]})`;
    if (strokeMatch) result.stroke = `var(--${strokeMatch[0]})`;
  }

  return result;
}

/** 替换 SVG 内部 fill/stroke 颜色 */
function applySvgColors(svg: string, { fill, stroke }: { fill?: string; stroke?: string }): string {
  if (!svg) return svg;

  if (fill) {
    svg = svg.replace(/\bfill\s*=\s*(['"]?)([^"'\s>]+)\1/gi, (m, _q, color) => (shouldPreserve(color) ? m : `fill="${fill}"`));
    svg = svg.replace(/<path(?![^>]*fill=)/gi, `<path fill="${fill}"`);
  }

  if (stroke) {
    svg = svg.replace(/\bstroke\s*=\s*(['"]?)([^"'\s>]+)\1/gi, (m, _q, color) => (shouldPreserve(color) ? m : `stroke="${stroke}"`));
    svg = svg.replace(/<path(?![^>]*stroke=)/gi, `<path stroke="${stroke}"`);
  }

  return svg;
}

// ====================  SVG 主处理函数  ====================
/** 整合 SVG 处理:清理 + 尺寸 + 颜色 */
function processSvg(svgText: string, props: Pick<IconProps, "color" | "className" | "style" | "size">): string {
  // 1️⃣ 清理
  svgText = sanitizeSvg(svgText);

  // 2️⃣ 确保存在 viewBox
  if (!svgText.includes("viewBox=")) {
    svgText = svgText.replace("<svg", '<svg viewBox="0 0 16 16"');
  }

  // 3️⃣ 若存在显式尺寸类 / props,则让 SVG 自适应外层容器
  const hasExplicitSize = hasSizeClass(props.className) || props.size || (props.style && (props.style.width || props.style.height));
  if (hasExplicitSize) {
    svgText = svgText.replace("<svg", '<svg width="100%" height="100%" preserveAspectRatio="xMidYMid meet"');
  } else {
    // 没有显式尺寸,给一个默认尺寸,比如 16x16
    svgText = svgText.replace("<svg", '<svg width="16" height="16" preserveAspectRatio="xMidYMid meet"');
  }

  // 4️⃣ 颜色处理
  const { fill, stroke } = extractSvgColor(props);

  if (!fill && !stroke) {
    // 若无显式颜色,默认加 fill="currentColor"
    svgText = svgText.replace("<path", '<path fill="currentColor"');
  } else {
    svgText = applySvgColors(svgText, { fill, stroke });
  }

  console.log(svgText);
  return svgText;
}

// ====================  组件主体部分     ====================
export default function Icon({ name, wrapperClass, className, color, style, size, fallback, onClick }: IconProps) {
  const [svgContent, setSvgContent] = useState<string>("");
  const [error, setError] = useState(false);
  const controllerRef = useRef<AbortController | null>(null);

  const iconPath = `/icons/${name}.svg`;

  useEffect(() => {
    if (!SVG_PATH_NAMES.includes(name)) {
      setError(true);
      return;
    }

    const loadSvg = async () => {
      controllerRef.current?.abort();
      controllerRef.current = new AbortController();

      if (svgCache.has(iconPath)) {
        // ✅ 缓存命中,不重新请求
        setSvgContent(svgCache.get(iconPath)!);
        return;
      }

      try {
        const res = await fetch(iconPath, { signal: controllerRef.current.signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const text = await res.text();
        cacheSet(iconPath, text);
        setSvgContent(text);
      } catch (e) {
        if (!(e instanceof DOMException && e.name === "AbortError")) {
          console.warn("❌ SVG load failed:", iconPath, e);
          setError(true);
        }
      }
    };

    loadSvg();
    return () => controllerRef.current?.abort();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [name]);

  const processedSvg = useMemo(() => {
    return processSvg(svgContent, { color, className, style, size });
  }, [svgContent, color, className, style, size]);

  /** 计算最终样式 */
  const finalStyle = useMemo(() => {
    const baseStyle: CSSProperties = {
      display: "inline-block",
      lineHeight: "0",
      flexShrink: "0",
      ...(size ? { width: size, height: size } : {}),
      ...(style ? style : {}),
    };
    return baseStyle;
  }, [style, size]);

  if (error) return <>{fallback ?? <span className="text-general-warning"></span>}</>;

  return (
    <div className={wrapperClass} onClick={onClick}>
      <div id={name} className={className} style={finalStyle} dangerouslySetInnerHTML={{ __html: processedSvg }} />
    </div>
  );
}

验证调用

执行后,你就能在项目中优雅地使用 <Icon name="tiktok" size={24} color="red" /> 等写法,图标会自动加载、清理、渲染并继承颜色。

代码示例:

import IconSvg from "./components/IconSvg";
export default function App() {
  return (
    <div className="p-20 flex flex-col gap-6">
      {/* 默认 */}
      <IconSvg name="wx" color="var(--text-primary-color)" />
      {/* 尺寸 */}
      <div className="flex items-center gap-4">
        <IconSvg name="profile/orders" style={{ width: 24, height: 24 }} />
        <IconSvg name="profile/orders" size={24} />
        <IconSvg name="profile/orders" className="w-6 h-6" />
        <IconSvg name="profile/orders" className="size-6" />
      </div>
      {/* 颜色 */}
      <div className="flex items-center gap-4">
        <IconSvg name="tiktok" style={{ color: "blue" }} />
        <IconSvg name="tiktok" color="red" />
        <IconSvg name="tiktok" color="var(--text-primary-color)" />
        <IconSvg name="tiktok" className=" fill-amber-600" />
      </div>
    </div>
  );
}

生成结果:

svg_examples.png

总结

本文介绍了一种 自动化 + 类型安全 + 高可维护 的 SVG 管理方案:

  • 使用脚本自动生成类型定义,避免手动维护。
  • 封装通用 Icon 组件,实现动态加载与安全清理。
  • 结合 TailwindCSS,让尺寸与颜色控制更加灵活

通过这套方案,你可以让项目中的图标管理更加高效、统一、可控。

感谢各位看官阅读,如果觉得这边文章帮到了您,希望您能点个赞~

Vue3后退不刷新,前进刷新

核心思想

  1. 使用keep-alive缓存页面信息
  2. 由于vue3中keep-alive只能通过组件名称缓存,每次都会动态创建一个新的组件包装下页面
  3. 每次路由前进时会根据路由的深度分配一个uuid并缓存,然后用此uuid创建一个动态组件包装页面
  4. 此方法可以解决a->b->a->c 然后后退 c->a->b->a的问题

PageKeepAlive组件源码

<template>
  <keep-alive :include="include">
    <component v-if="node" :is="node" :key="data.cid" />
  </keep-alive>
</template>
<script setup>
import { uuid } from '@/common/utils/StringUtil'
import { defineComponent, computed, reactive, nextTick } from 'vue'
import {useRouter} from 'vue-router'
const props = defineProps(['data'])

const router = useRouter()

const comp_cache = reactive({})
const position_cache = reactive({})

const include = computed(()=>{
  let target = []
  let expired = new Set(Object.keys(comp_cache))
  for (let cache of Object.values(position_cache)) {
    target.push(cache.cid)
    expired.delete(cache.cid)
  }
  nextTick(()=>{ // 删除过期的缓存
    for (let cid of expired) {
      delete comp_cache[cid]
    }
  })
  return target
})

const node = computed(()=>{
  let data = props.data
  if(!data || !data.cid || !data.comps) return null
  if (!comp_cache[data.cid]) {
    comp_cache[data.cid] = defineComponent({
      name: data.cid,
      setup() {
        return () => data.comps
      }
    })
  }
  return comp_cache[data.cid]
})

router.afterEach((to) => {
  let current = window.history.state.position
  let path = to.path
  for (let i of Object.keys(position_cache)) {
    let cache = position_cache[i]
    if (i > current) { // 大于当前位置的缓存清理掉
      delete position_cache[i]
    }
    if (i == current && cache.path != path) { // replace需要去掉缓存
      delete position_cache[i]
    }
  }
  let cache = position_cache[current]
  if (!cache) {
    position_cache[current] = cache = {
      cid: `comp_${uuid()}`,
      path
    }
  }
  to.meta.cid = cache.cid
})
</script>

使用方式

<router-view v-slot="{ Component, route  }">
<PageKeepAlive :data="{cid: route.meta.cid, comps: Component}" />
</router-view>

Flutter 之魂 GetX🔥(三)深入掌握依赖管理

🪶序言

🧩什么是依赖?

依赖 就是一个对象运行时需要用到的另一个对象。

例子:

class Car {
  Engine v8 = Engine(); // Car 依赖 Engine
}

Car 离不开 Engine,这就是依赖关系

⚙️ 什么是依赖注入?

依赖注入 就是把对象的创建交给外部系统(或框架)完成。

简单说:
👉 让别人帮我 new,而不是我自己 new

例子:

class Car {
  final Engine v8;
  Car(this.v8); // 依赖由外部注入
}

💡优点

  • 解耦:Car 不关心 Engine 怎么创建
  • 可替换:方便测试或更换实现类

GetX - 依赖管理(Dependency Management)

1. 基本概念

GetX 依赖管理是一种框架机制,用于统一创建、提供、访问和销毁对象,实现对象的集中管理和生命周期控制

它让你不用在代码里到处写 new,而是由框架帮你管理对象的生命周期。

核心思想

  1. 注入(Injection)
    框架帮你创建对象,你只负责使用,不关心对象如何生成。
    🔹 就像你去餐厅,只需要吃菜,不需要自己下厨。
  2. 管理(Management)
    框架管理对象的生命周期:自动销毁、永久存在或手动替换。
  3. 访问(Access)
    注册过的对象可以在任何地方获取,无需传参或重复创建。

💡总结:
框架帮你 new、管你用、自动收拾。
这就是 GetX 依赖管理的精髓。

完整流程示意图如下:

graph TD
依赖注册 --> 立即创建Get.put
依赖注册 --> 懒加载Get.lazyPut
依赖注册 --> 异步创建Get.putAsync
依赖注册 --> 每次新建Get.create
立即创建Get.put --> 获取依赖Get.find
懒加载Get.lazyPut --> 获取依赖Get.find
异步创建Get.putAsync --> 获取依赖Get.find
每次新建Get.create --> 获取依赖Get.find
获取依赖Get.find --> 生命周期管理
生命周期管理 --> 随页面自动销毁
生命周期管理 --> 永久依赖permanent:true
生命周期管理 --> 替换依赖Get.replace
随页面自动销毁 --> 依赖销毁
永久依赖permanent:true --> 依赖销毁
替换依赖Get.replace --> 依赖销毁
依赖销毁 --> 手动销毁Get.delete
依赖销毁 --> 清空所有Get.deleteAll

2. 依赖注册

①. Get.put

  • 功能:立即实例化并注册依赖。
  • 使用场景:应用启动或页面打开时就需要用到的依赖。
  • 参数
参数名 类型 是否必填 默认值 说明
dependency S 要注册的依赖对象实例,例如控制器或服务类对象。
tag String null 可选标签,用于区分同一类型的多个实例,常用于多控制器并存场景。
permanent bool false 是否永久依赖:
true → 对象不会随页面销毁(常驻内存)
false → 页面销毁时自动释放。
builder InstanceBuilderCallback<T> null 可选回调,用于懒加载或自定义对象的创建逻辑(一般不与 dependency 同时使用)。
  • 示例
Get.put(HomeController(), tag: "home", permanent: false);

②. Get.lazyPut

  • 功能:懒加载,仅在第一次使用时创建实例。
  • 使用场景:控制器或服务不是每次都需要时,节省内存资源。
  • 参数
参数名 类型 是否必填 默认值 说明
builder S Function() 必选回调,用于创建依赖对象,第一次使用时才会执行。
tag String null 可选标签,用于区分同一类型的多个实例。
fenix bool false 是否开启“复活”功能:
true → 对象被释放后再次调用 Get.find 会重新创建
false → 对象被释放后再次调用会报错。
  • 示例
Get.lazyPut<HomeController>(() => HomeController(), tag: "home", fenix: true);

③. Get.putAsync

  • 功能:用于异步实例化依赖对象。
  • 使用场景:当对象创建需要异步操作(如数据库初始化、网络加载等)。
  • 参数
参数名 类型 是否必填 默认值 说明
builder Future<S> Function() 必选异步回调,用于创建对象。
tag String null 可选标签,用于区分同一类型的多个实例。
permanent bool false 是否永久依赖:
true → 对象不会随页面销毁
false → 页面销毁时自动释放。
  • 示例
Get.putAsync<HomeController>(
  () async {
    await Future.delayed(Duration(seconds: 1));
    return HomeController();
  },
  tag: "home",
  permanent: false,
);

④. Get.create

  • 功能:每次调用 Get.find 都会创建一个新的实例。
  • 使用场景:适用于需要临时对象或频繁刷新实例的场景。
  • 参数
参数名 类型 是否必填 默认值 说明
builder S Function() 必选回调,用于每次创建新的依赖对象。
tag String null 可选标签,用于区分同一类型的多个实例。
permanent bool true 是否永久依赖:
true → 默认值,对象不会随页面销毁
false → 对象随页面销毁。
  • 示例
Get.create<HomeController>(() => HomeController(), tag: "home", permanent: true);

总结

方法 注册时机 返回实例 是否可重复创建 常用场景 主要参数说明
Get.put<T> 立即注册(立即创建对象) 单例(同类型唯一) 需要立即使用的控制器或服务 dependency: 实例对象
tag: 区分实例
permanent: 是否永久存在
Get.lazyPut<T> 延迟注册(首次使用时创建) 单例(惰性加载) ✅ (可通过 fenix 重建) 仅在用到时才创建的控制器 builder: 对象构造函数
fenix: 被销毁后是否可重建
tag: 标签标识
Get.putAsync<T> 异步注册(等待 Future 完成) 单例 需要异步初始化的依赖(如数据库、SharedPreferences) builder: 异步构造函数
permanent: 是否永久存在
tag: 标签
Get.create<T> 每次调用时创建新实例 非单例(每次新的) 每次都要新建实例的对象 builder: 构造函数
tag: 标签
permanent: 是否永久存在

3. 依赖获取

GetX 中,已经注册的依赖对象可以通过 Get.find<T>() 在任何地方获取,无需手动传递或 new 对象。

①. 基本用法

// 获取默认注册的依赖对象
final homeController = Get.find<HomeController>();

// 如果注册时使用了 tag,需要传入相同标签
// tag: 可选标签,用于区分同一类型的多个实例
final loginController = Get.find<HomeController>(tag: "loginPage");

②. 特点概述

  • 全局访问:注册后的对象可以在任意地方调用 Get.find 获取
  • 类型安全:泛型保证返回对象类型正确
  • 支持标签:区分同一类型的多个实例
  • 生命周期一致:获取到的对象遵循注册时的生命周期规则

③. 补充说明

  • 获取依赖对象时,如果对象不存在,会抛出异常
  • 对于懒加载对象(Get.lazyPut)或异步对象(Get.putAsync),第一次 Get.find 会触发创建

4. 依赖销毁

GetX 中,依赖对象不仅可以自动销毁(随页面生命周期),也可以 手动销毁全部清空 ,从而实现灵活的内存与资源管理。

①. Get.delete

  • 功能:手动销毁指定类型的依赖对象。
  • 使用场景:当对象不再需要时手动释放资源,或在页面关闭后主动清理。
  • 参数
参数名 类型 是否必填 默认值 说明
tag String null 如果注册时使用了标签,销毁时也需传入相同标签。
force bool false 是否强制销毁:true → 即使对象被标记为 permanent 也会被移除false → 默认值,永久依赖不会被销毁。
  • 示例
// 销毁默认依赖对象
Get.delete<HomeController>();

// 销毁带标签的依赖对象
Get.delete<HomeController>(tag: "home");

// 强制销毁永久依赖
Get.delete<HomeController>(force: true);

②. Get.deleteAll

  • 功能:一次性清空所有已注册的依赖对象。
  • 使用场景:适用于应用重启、退出登录、清理缓存等场景。
  • 示例
// 清空所有依赖对象
Get.deleteAll();

③. 自动销毁机制

  • 当依赖注册时未设置 permanent: true,对象会随页面关闭自动销毁
  • 若设置 permanent: true,则必须手动销毁才能释放资源

💡 总结:

  • Get.delete 用于销毁单个依赖
  • Get.deleteAll 用于清空全部依赖,默认依赖会随页面自动释放。

5. 依赖替换

Get.replaceGetX 中用于替换已注册依赖对象 的方法。它的作用是把原来的依赖对象换成新的实例,而不需要手动先删除再注册,可以理解为 更新容器里的依赖

  • 功能

    • 替换已有依赖对象实例
    • 保持依赖容器中类型和标签不变
    • 自动处理生命周期(会销毁旧对象,注册新对象)
  • 使用场景

    1. 热重载或刷新控制器: 页面逻辑变更,需要新的实例覆盖旧实例。
    2. 动态切换服务实现: 比如切换网络请求客户端、用户数据服务等。
    3. 替换依赖而不影响其他地方调用: Get.find<HomeController>() 依然能获取到新实例。
  • 示例

// 原来的控制器
Get.put<HomeController>(HomeController());

// 替换成新实例
Get.replace<HomeController>(HomeController());

// 如果原来注册时用了 `tag` 或 `permanent`,可以一起替换:
Get.replace<HomeController>(
  HomeController(),
  tag: "home",
  permanent: true,
);

6. Bindings

Bindings 是 GetX 中的 依赖注入桥梁,用于在页面加载前自动注册所需的控制器和服务。
它把依赖的创建逻辑从页面中分离出来,实现页面与依赖的解耦自动管理

Bindings 就是「页面加载前,自动执行依赖注册」的机制。

①. 基本使用

(1) 定义 Binding 类

通过继承 Bindings 并重写 dependencies() 方法注册依赖。

class HomeBinding extends Bindings {
  @override
  void dependencies() {
    // 懒加载控制器
    Get.lazyPut<HomeController>(() => HomeController());

    // 永久依赖的全局服务
    Get.put<AuthService>(AuthService(), permanent: true);
  }
}
(2) 路由中绑定 Binding
GetPage(
  name: '/home',
  page: () => HomePage(),
  binding: HomeBinding(),
);

当用户进入 /home 页面时,HomeBinding.dependencies() 会自动执行,
HomeControllerAuthService 会被注册到依赖管理容器中。

(3) 多 Binding 绑定
GetPage(
  name: '/dashboard',
  page: () => DashboardPage(),
  bindings: [
    HomeBinding(),
    UserBinding(),
    SettingBinding(),
  ],
);
(4) 动态绑定(BindingsBuilder)

适合小页面或一次性绑定:

GetPage(
  name: '/login',
  page: () => LoginPage(),
  binding: BindingsBuilder(() {
    Get.put(LoginController());
  }),
);

②. 机制原理

展示:

flowchart TD
页面跳转请求-->是否配置Binding?
是否配置Binding?--是-->执行Binding.dependencies
是否配置Binding?--否-->直接进入页面
执行Binding.dependencies-->注册依赖到GetInstance容器
注册依赖到GetInstance容器-->依赖是否永久
依赖是否永久--是-->标记为permanent页面销毁时保留
依赖是否永久--否-->标记为临时依赖页面销毁时释放
标记为permanent页面销毁时保留-->页面初始化
标记为临时依赖页面销毁时释放-->页面初始化
页面初始化-->控制器与服务注入页面
控制器与服务注入页面-->页面正常运行
页面正常运行-->页面销毁
页面销毁-->依赖是否永久?
依赖是否永久?--否-->从容器移除依赖释放资源
依赖是否永久?--是-->保留依赖供复用
从容器移除依赖释放资源-->页面销毁完成
保留依赖供复用-->页面销毁完成

核心机制说明:

  1. 路由检测阶段
    当执行 Get.to()Get.off() 跳转页面时,GetX 会检查目标 GetPage 是否配置了 binding
  2. 依赖注册阶段
    若存在绑定,框架会自动调用该 Bindingdependencies() 方法,将其中定义的依赖通过
    Get.put()Get.lazyPut() 等方法注册进 全局依赖容器 GetInstance()
  3. 页面初始化阶段
    页面构建时,注册的依赖对象被自动注入(如控制器、服务等),页面可直接通过 Get.find() 获取实例。
  4. 页面销毁阶段
    当页面被关闭时,所有 permanent = false 的依赖会自动释放;
    仅有 permanent = true 的依赖会常驻内存,直到手动删除或程序结束。

7. 管理机制

GetX 的依赖管理机制通过 SmartManagement 控制依赖对象在页面生命周期中的自动释放和保留策略。
可以理解为依赖管理的 智能策略,决定依赖在页面关闭时是否释放或复用。

①. SmartManagement.full

  • 功能:页面销毁时,自动释放所有注册的依赖,包括非 permanent 的依赖。
  • 使用场景:适合页面之间独立、依赖对象不需要跨页面复用的场景。
  • 示例
GetMaterialApp(
  initialRoute: '/home',
  smartManagement: SmartManagement.full,
  getPages: [...],
);
  • 特点
    • 页面关闭时,非永久依赖全部释放
    • 控制器不会残留,节省内存
    • 默认策略

②. SmartManagement.onlyBuilders

  • 功能:页面销毁时,只释放 通过 GetBuilder 创建的依赖Get.put / Get.lazyPut 的依赖保持不变。
  • 使用场景:希望保留全局或懒加载依赖,仅释放页面局部依赖时使用。
  • 示例
GetMaterialApp(
  smartManagement: SmartManagement.onlyBuilders,
);
  • 特点
    • 页面关闭时只清理局部依赖
    • 全局依赖、懒加载依赖不会被销毁
    • 适合控制器跨页面复用的场景

③. SmartManagement.keepFactory

  • 功能:页面销毁时,依赖保持不释放,页面再次打开时复用原实例。
  • 使用场景:依赖对象需要在多个页面间共享,或者页面频繁切换时希望避免重复创建。
  • 示例
GetMaterialApp(
  smartManagement: SmartManagement.keepFactory,
);
  • 特点
    • 页面关闭不会销毁依赖
    • 所有依赖都可以跨页面复用
    • 适合全局服务或单例控制器

总结对比

策略 页面销毁行为 适用场景
full 非永久依赖全部释放 页面独立,节省内存
onlyBuilders 只释放 GetBuilder 创建的依赖 保留全局或懒加载依赖
keepFactory 所有依赖保留,不释放 跨页面复用,单例控制器

8. 依赖作用域

GetX 中,依赖对象的作用域决定了对象 生命周期、可访问性和复用方式
主要分为 页面局部依赖全局依赖

①. 页面局部依赖(Page Scoped Dependency)

  • 定义:依赖对象只在当前页面存在,页面关闭后自动销毁。

  • 注册方式

    • Get.put(controller)(默认 permanent: false
    • Get.lazyPut(controller)(默认非永久)
  • 特点

    • 生命周期绑定页面
    • 节省内存,页面关闭后自动释放
    • 不同页面可以注册同类型控制器而互不影响

②. 全局依赖(Global Dependency)

  • 定义:依赖对象在应用整个生命周期中存在,跨页面可复用。

  • 注册方式

    • Get.put(controller, permanent: true)
    • Get.lazyPut(controller, fenix: true)(复活机制,释放后可再次创建)
  • 特点

    • 生命周期跨页面
    • 多页面共享同一个实例
    • 适合全局服务、单例控制器

总结

类型 生命周期 跨页面访问 常用场景
页面局部依赖 页面关闭销毁 页面专属控制器或服务
全局依赖 程序生命周期/永久 用户状态管理、全局服务、单例控制器

从 AOP 到代理:Spring 事务注解是如何生效的?

原文来自于:zha-ge.cn/java/122

从 AOP 到代理:Spring 事务注解是如何生效的?

第一次用 @Transactional 的时候,我的心情是这样的:

“太神奇了!啥也不用写,就能自动回滚事务!Spring 也太懂我了吧~”

结果没过几天,Bug 就给我一记重拳——明明加了 @Transactional,数据却压根没回滚。
这让我第一次意识到: @Transactional 并不是魔法,它的生效背后是一整套 AOP + 代理的机制在兜底。

今天,我们就来扒一扒这件“看似简单”的事背后,Spring 究竟做了多少骚操作。


那个“明明加了注解却不回滚”的坑

有一次我在做订单模块,写了个非常简单的服务:

@Service
public class OrderService {

    @Transactional
    public void createOrder() {
        orderRepository.save(...);
        throw new RuntimeException("测试回滚");
    }
}

我以为抛出异常后事务肯定会回滚,结果——数据进了库,没回滚
我当场人都傻了。

最后定位到罪魁祸首:我是在同一个类里调用了这个方法

public void process() {
    createOrder(); // 自调用
}

这次事件给我上了一课:@Transactional 能否生效,和“有没有代理”息息相关


Step 1:事务注解只是个“标记”

我们都知道,@Transactional 是用来声明事务的,比如:

@Transactional
public void saveUser() { ... }

但注解本身不做任何实事,它只是个元数据标记。真正“让它生效”的,是 Spring 容器在解析 Bean 时,扫描到这个注解,然后做了一系列“增强”的操作。


Step 2:AOP 出场,事务被“织”进去

当 Spring 扫描到一个带有 @Transactional 的类或方法时,它会做什么?

👉 它不会直接改你的方法逻辑,而是:用 AOP 机制为这个 Bean 生成一个代理对象

这个代理对象在方法执行前后,会织入一段“事务处理逻辑”:

[代理方法执行流程]

1. 开启事务
2. 执行目标方法(你的业务逻辑)
3. 如果没有异常 → 提交事务
4. 如果抛出异常 → 回滚事务

所以,当你调用这个方法的时候,其实不是在直接调你的原方法,而是在调一个“包装了事务逻辑”的代理。


Step 3:代理是怎么生成的?

Spring 有两种常见的代理方式:

  • JDK 动态代理:如果你的 Bean 实现了接口,Spring 会创建一个基于接口的代理对象。
  • CGLIB 动态代理:如果没有接口,Spring 会为你的类生成一个子类代理。

比如:

public interface OrderService {
    void createOrder();
}

@Service
public class OrderServiceImpl implements OrderService {
    @Transactional
    public void createOrder() { ... }
}

容器启动时,Spring 不直接注入 OrderServiceImpl,而是注入它的 代理对象
调用 createOrder() 的时候,代理先织入事务逻辑,再调原始方法。


Step 4:事务管理器接手全局

代理本身不负责事务,它只是“调用前的拦截点”。
真正的事务逻辑由 事务管理器(PlatformTransactionManager 接管:

TransactionStatus status = transactionManager.getTransaction(def);
try {
    method.invoke(target, args);
    transactionManager.commit(status);
} catch (Throwable ex) {
    transactionManager.rollback(status);
    throw ex;
}

这就是 Spring 在幕后帮你写的“try-catch-commit-rollback”逻辑。


踩坑瞬间:事务不生效的 4 大经典原因

@Transactional 最容易踩的坑,基本都和“代理机制”有关:

  1. 自调用导致绕过代理
    在同一个类内部调用事务方法,不会经过代理,事务不生效。
  2. 方法不是 public
    默认的事务代理只会拦截 public 方法,private / protected 都拦不住。
  3. 异常被 catch 掉了
    Spring 默认只对 RuntimeExceptionError 回滚,如果你捕获了异常或抛的是受检异常,事务也不会回滚。
  4. Bean 没被 Spring 管理
    自己用 new 创建的对象没有代理,自然不会触发事务机制。

面试官杀手锏回答

问:Spring 的 @Transactional 是怎么生效的?

答题模板👇:

  • @Transactional 本质只是元数据标记,不具备事务功能;
  • Spring 在解析 Bean 时,会通过 AOP 创建代理对象
  • 调用事务方法时,会先进入代理逻辑,代理再调用事务管理器开启事务;
  • 方法执行后,根据是否抛出异常决定提交还是回滚;
  • 因为依赖代理,所以 自调用、非 public 方法、异常未抛出 都可能导致事务失效。

一句话总结: @Transactional 的核心在于 AOP 代理 + 事务管理器的协作。


写在最后:注解不是魔法,底层才是本事

很多人第一次用 Spring 事务时,都会掉进“注解即万能”的陷阱里。
但真正理解了代理和 AOP 的角色后,你才会明白:

  • 注解只是声明意图
  • AOP 才是织入逻辑的入口
  • 事务管理器才是落地执行者

所以,会用 @Transactional 是入门,会解释它为什么生效,才是你和别人拉开差距的地方。

下次再被问到“Spring 事务是怎么实现的”,别再只说“靠注解”,说出“代理、AOP、事务管理器”这三件套,面试官会对你另眼相看。

个人写码感悟:TailwindCSS不要忽视子选择器

前段时间刷小红书看到这样一个贴子,其中作者配了这样一张图:

用 Tailwind,就不要忽视子选择,减少代码量_1_咸蛋黄_来自小红书网页版.jpg

感觉很受启发,有时候自己嫌麻烦会在子组件上重复复制粘贴很多遍相同的样式代码,实际在其父组件中通过子选择器就可避免代码冗余的情况,刷过之后留了个印象。

今天在使用shadcn/ui的tabs组件时,我为了设置每个tab再悬浮时光标手势为pointer,未激活时字体为白色,背景为灰色,悬浮背景为浅灰色;激活时字体为黑色,背景为白色,悬浮背景不变色,我忽然想起了这个贴子,便想实际操作一下。

按照以往的写码习惯这些央视的编写会是下面这样:

<TabsList className="flex-none w-full bg-gray-800 border-b border-gray-700 rounded-none h-auto">
    {CheckComponent && (
        <TabsTrigger
            value="check"
            className="flex-1 cursor-pointer text-white hover:bg-gray-700 data-[state=active]:text-black data-[state=active]:hover:bg-transparent"
        >
            Check
        </TabsTrigger>
    )}
    {CreateComponent && (
        <TabsTrigger
            value="create"
            className="flex-1 cursor-pointer text-white hover:bg-gray-700 data-[state=active]:text-black data-[state=active]:hover:bg-transparent"
        >
            Create
        </TabsTrigger>
    )}
    {EditComponent && (
        <TabsTrigger
            value="edit"
            className="flex-1 cursor-pointer text-white hover:bg-gray-700 data-[state=active]:text-black data-[state=active]:hover:bg-transparent"
        >
            Edit
        </TabsTrigger>
    )}
</TabsList>

而利用子选择器思路则是直接在每个TabsTrigger的父组件上用父级规则批量设置子组件样式:

<TabsList className="flex-none w-full bg-gray-800 border-b border-gray-700 rounded-none h-auto 
    [&_button]:cursor-pointer 
    [&_button]:text-white 
    [&_button:not([data-state=active])]:hover:bg-gray-700 
    [&_button[data-state=active]]:text-black"
>
    {CheckComponent && (
        <TabsTrigger value="check" className="flex-1">
            Check
        </TabsTrigger>
    )}
    {CreateComponent && (
        <TabsTrigger value="create" className="flex-1">
            Create
        </TabsTrigger>
    )}
    {EditComponent && (
        <TabsTrigger value="edit" className="flex-1">
            Edit
        </TabsTrigger>
    )}
</TabsList>

这样做的优点在于方便后期做整体样式调整,而无需对每个TabsTrigger做一对一的修改,有些避免代码冗余,降低维护难度。

本地开发环境获取远程App端环境-研发提效小技巧

 1. 背景说明

我们在开发H5项目时候,当投放到App的webview中时候,在开发阶段,我们需要获取到App的登录态(token、设备信息等等),或者联调分享的时候,也是需要App环境,来实现分享功能的自测,还有某些场景,我们需要往App本地存储里读写缓存数据。还有的时候,可能需要测试多个手机系统的App的兼容性问题。

以上场景,有个致命的要求,就是很多情况需要本地将登录态写死,或者假装功能已经通了,实际上还是需要发布测试环境,或者想办法让手机能打开我本地正在开发的页面,来实现功能的验证。

为解决这个问题,曾经也写过一个小技巧,本地实现一个jssdk的mock服务,也能实现部分功能,但要更进一步提升开发效率,还是有一定的壁垒。为此,搞了一个更进阶的方式来解决以上问题-【本地开发环境使用真机App环境】,该功能支持开发环境连接多端,甚至可跨地区连接远端App。

2. 先说结论:好使

该功能适用于开发调试投放到公司内部App如:『智家App』、『移动工作台』、『三翼鸟App』等中的H5项目。因这几个App框架一样,本地开发H5项目的时候,完全可以通过该功能来获取真机的环境数据,如果需要测试页面跳转、分享微信、分享小程序、唤起面板,也完全可以实现本地开发环境的自测要求。为开发效率的提升、为真机bug问题的排查,都是有一定的作用与帮助的。

3. 架构设计

3.1架构图

20251016-163432.jpg

3.2 时序图

20251016-163440.jpg

4. 实现方案

4.1 核心功能模块

4.1.1 MockUplusApi

  1. 通过远程页面创建的唯一标识ID,与服务端建立Socket通信,实现实时接收响应数据
  2. 发送Beacon数据,发送开发者调用的UplusApi对应的方法数据
  3. 配置UplusApi方法,因此文件是模拟UplusApi包的,因此调用方法不全,需要开发者根据实例,配置自己需要的模拟方法

4.1.2 NodeServer

  1. Server-用于接收开发端的UplusApi请求数据,并将数据处理,然后通过Socket发送数据给远程App环境
  2. SocketServer-用于将本地开发环境与远程App环境建立连接,实现实时数据通信

4.1.3 RemotePage(远程载体页面)

  1. 生成唯一标识ID,用于开发者指定链接调试的App环境
  2. 执行服务端发来的调用UplusApi的方法指令
  3. 与服务端建立Socket通信,将执行UplusApi后的返回数据,格式化后发送给Server端

4.2 业务接入及影响

4.2.1 Uplusapi Mock包

可自行配置新增函数

// mock/uplus-api.js
// 以下几个方法,是我们常用的模块,按这个格式配置即可,实现获取远程App环境的真是数据
// 网络模块
upNetworkModule = {
    isOnline: async() => {
        const reqData = {eventModule: 'upNetworkModule', eventName: 'isOnline'}
        return await getAppData(reqData, code)
    }
}
// 用户信息模块
upUserModule = {
    getLoginStatus: async() => {
        const reqData = {eventModule: 'upUserModule', eventName: 'getLoginStatus'}
        return await getAppData(reqData, code)
    },
    getUserInfo: async() => {
        const reqData = {eventModule: 'upUserModule', eventName: 'getUserInfo'}
        return await getAppData(reqData, code)
    }
}

4.2.2 编译配置修改

vite.config.js
// 开发环境使用MockUplusApi, 非开发环境使用真正的@uplus/uplus-api(jssdk)
resolve: {
  alias: {
    // 使用绝对路径配置别名
    '@uplus/uplus-api': isDev 
      ? fileURLToPath(new URL('./mock/uplus-api.js', import.meta.url))
      : '@uplus/uplus-api',
  }
}
vue.config.js
// 开发环境使用MockUplusApi, 非开发环境使用真正的@uplus/uplus-api(jssdk)
config.resolve.alias
    .set(
        '@uplus/uplus-api', isDev
        ? './mock/uplus-api.js'
        : '@uplus/uplus-api'
        )

4.2.3 影响-因对业务代码无任何侵入,调用UplusApi的方法完全一致,故无任何影响

  1. 本地开发环境,使用mock/uplus-api文件
  2. 测试环境、生成环境,或自定义的环境,都可以通过编译配置文件,进行自定义的使用
  3. 项目中的对UplusApi(jssdk)的调用方法,完全保持一致

4.3 使用方法

  1. 真机App,通过扫码 或 链接方式,打开远程页面,获取到唯一编码标识
  2. 本地Mock包内配置真机获取到的唯一编码标识,实现本地连接远程真机
  3. 启动业务项目,调用UplusApi(jssdk)方法,即可实现通信,获取App返回的数据,或唤起分享功能
  4. 若需要连接多台真机,需自行更换Mock包内的编码标识(有多台手机,或异地手机,均可扫码生成唯一标识,本地环境配置哪个编码,即可联机那台手机)

5. 扩展思考

这是一个纯前端实现的方案,没有和端上的开发人员有交流沟通。如果在端上默认植入一个载体(仅限开发环境入驻),那如上设计中的前端载体页,应该就不用了。通过socket,可以实时实现本地开发环境连接App,这种方案应该是最好的开发模式。(纯意淫瞎想,不知道会不会有什么没考虑到问题)

6. 团队介绍

三翼鸟数字化技术平台-定制平台开发」主要负责设计工具的研发,包括营销设计工具、家电VR设计和展示、水电暖通前置设计能力,研发并沉淀素材库,构建家居家装素材库,集成户型库、全品类产品库、设计方案库、生产工艺模型,打造基于户型和风格的AI设计能力,快速生成算量和报价;同时研发了门店设计师中心和项目中心,包括设计师管理能力和项目经理管理能力。实现了场景全生命周期管理,同时为水,空气,厨房等产业提供商机管理工具,从而实现了以场景贯穿的B端C端全流程系统。

前端获取设备视频流踩坑实录

前言

众所周知,当使用http访问设备摄像头、麦克风等,需要在浏览器中配置安全策略。但是也会有其他问题。

最近,博主遇到了这样一个问题,在本地开发时,可以正常访问摄像头和麦克风,在局域网内,其他用户通过IP访问我的开发环境,在配置了浏览器安全策略后,也可以访问麦克风摄像头。但是当项目上线时,在同样未配置证书的情况下,使用http访问前端,则无法访问摄像头,调用截图方法时却能正确调用,可以拿到当前视频帧,浏览器也没有任何报错。

本来是通过https访问的,但是由于后端未通过加密通道传输,项目也没有正式上线,只是处于demo状态,所以还是改为http访问。如果一定要通过https访问,那么访问接口的地址也必须是https/wss,否则属于混合内容,会被浏览器拦截。 使用https的情况下,有两种解决方案:

  1. 后端需要支持TLS握手并提供“可信证书 + 主体匹配”(证书的 SAN 需匹配域名/IP)。否则会变成 TLS/证书错误(如NET::ERR_CERT_AUTHORITY_INVALID)
  2. 在前端同源的网关/Ingress/Nginx 暴露一个 WSS 路径(同源同证书),再反向代理到后端。

排除问题

一、检查元素大小是否被影响?

通过devtools发现元素布局正常,大小无变化,未被遮挡。

二、没有正确设置 autoplay / playsinline 属性

在一些浏览器中,尤其是移动端,如果没有 autoplay(页面加载后“尝试”自动播放视频。)muted(将音轨静音)playsinline(允许在页面内联播放) 属性,视频流会卡在“有流但不播放”的状态,导致黑屏但能截图。 但是我全加了,排除

三、检查 CSP、安全头或者 iframe sandbox 限制

如果视频是在 iframe 或某个带有 CSP 头的环境中(例如 Content-Security-Policy: default-src 'self'),可能媒体流被允许但渲染被限制。

查看头部信息,未发现任何限制

四、video 还没进入播放状态

在以上答案都排除后,发现有流但是未被播放(因为截图能拿到视频帧),考虑是video标签为正确绑定流。 在控制台中打印video的状态:

const video = document.querySelector('video');
console.log(video.readyState, video.paused);

获得的结果:

0 true

补充说明 readyState 的取值含义:

含义 说明
0 HAVE_NOTHING 还没有任何关于视频的信息
1 HAVE_METADATA 读取到了元数据(宽高、时长等)
2 HAVE_CURRENT_DATA 有当前帧数据,但可能不足以播放
3 HAVE_FUTURE_DATA 有未来帧,但可能不够流畅
4 HAVE_ENOUGH_DATA 可以正常播放

理想情况下,应该看到 readyState === 4,并且 video.paused === false。 如果 readyState 长期卡在 2 或 3,而 pausedtrue,那就是 video 播放没有真正触发。这种情况下就算截图有帧,页面也会一直黑屏。

也就是说,srcObject 并没有被正确赋值或者 video 元素还没在文档流中,或者被浏览器静默拦截。

我之前的代码为:

useEffect(() => {
  let isMounted = true
  setIsLoading(true)
  const startCamera = async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: { facingMode: 'user' },
        audio: false
      })
      if (!isMounted) return
      streamRef.current = stream
      if (videoRef.current) {
        videoRef.current.srcObject = stream
        await videoRef.current.play().catch(() => {})
      }
    } catch {
      setError('无法访问摄像头,请检查权限')
    } finally {
      setIsLoading(false)
    }
  }
  startCamera()
  return () => {
    isMounted = false
    if (streamRef.current) {
      streamRef.current.getTracks().forEach(track => track.stop())
      streamRef.current = null
    }
  }
}, [])

首先考虑是否自动播放被拦截了,由于await videoRef.current.play().catch(() => {})catch块里没有任何处理,如果是被拦截了不会报错,我们加上错误处理后,发现并没有走到catch块了,证明并非是播放被拦截。

查看整个代码逻辑,如果没有任何报错,srcObject也赋值了视频流。那就只有一个可能:video标签挂载时间比获取媒体流的时间要晚,尤其是在dom结构上,设置了loading状态结束后才渲染video标签,更能证明这一点。

{isLoading ? (
    <div className='text-white'>加载中...</div>
  ) : error ? (
    <div className='text-white gap-4 flex flex-col items-center'>
      <SvgIcon iconName='Error' />
      {error}
    </div>
  ) : (
    <>
      <video
        ref={videoRef}
        autoPlay
        playsInline
        muted
        className='w-full h-full object-contain rounded-2xl'
      />
    </>
  )}

但是为什么开发环境下可以正常访问呢?就是“时序竞态”问题: 在生产环境里 getUserMedia 返回得比<video>挂载更早,首次调用发生在 videoRef 还为 null 时,绑定被丢失;开发环境因为 StrictMode/HMR 导致 effect 被再次执行或整体更慢,恰好补上了这次遗漏,所以看不出问题。 按照我的理解,通过设置断点:

useEffect(() => {
  let isMounted = true
  setIsLoading(true)
  debugger // 1
  const startCamera = async () => {
    debugger // 2
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: { facingMode: 'user' },
        audio: false
      })
      console.log(isLoading)
      debugger // 3
      setVideoId(videoId + 1)
      if (!isMounted) return
      streamRef.current = stream
      if (videoRef.current) {
        videoRef.current.srcObject = stream
        await videoRef.current.play().catch(() => {})
      }
    } catch {
      setError('无法访问摄像头,请检查权限')
    } finally {
      debugger //4
      setIsLoading(false)
    }
  }
  startCamera()
  return () => {
    debugger // 5
    isMounted = false
    if (streamRef.current) {
      streamRef.current.getTracks().forEach(track => track.stop())
      streamRef.current = null
    }
  }
}, [])

开发环境执行顺序为:

  1. 执行第一次useEffct,执行到debugger2处,遇到await挂起,effect返回。
  2. 执行第二次useEffct,依旧执行到debugger2处,遇到await挂起
  3. 等待第一次await恢复,执行到setLoading(false)处,video挂载。
  4. 等待第二次await恢复,执行到debugger3处,videoRef.current此时已有值,成功挂载视频流。

如有错误请指正,非常感谢!

以 NestJS 为原型看懂 Node.js 框架设计:Provider Scope

前言

NestJS 的依赖注入系统支持 三种 Provider Scope:Singleton、Request 和 Transient。理解这些 Scope 对于设计稳定、高性能的服务依赖链至关重要。

Provider Scope

概览

在 NestJS 中,每个 Provider 都有一个 作用域(Scope) ,它决定了实例化的时机和生命周期:

Scope 含义 实例化时机 典型应用场景
Singleton(默认) 全局单例 启动阶段 大部分服务、全局工具类
Request 每个请求创建一个实例,与请求上下文绑定,避免跨请求数据污染 请求阶段 保存请求上下文状态,如用户信息 req.user
Transient 每次调用生成新实例 由调用方决定 短生命周期、工具类、多次独立实例

为什么要区分启动阶段和请求阶段?(ContextId)

从上表可以看出,不同 Scope 的 Provider 实例化时机不同,分为启动阶段请求阶段,为了在框架内部统一管理这些实例,同时保证 Singleton ScopeRequest Scope 的隔离,NestJS 为每个实例化过程引入了一个上下文标识 ContextId

interface ContextId {
  readonly id: number;
}

const STATIC_CONTEXT: ContextId = Object.freeze({ id: 1 });
  • 启动阶段 → 使用 STATIC_CONTEXT,表示全局单例
  • 请求阶段 → 使用 { id: 随机数 },表示每个请求独立上下文

InstanceWrapper 与实例存储结构(Singleton / Request / Transient)

每个 Provider 可能在 启动阶段请求阶段 被实例化多次。为了管理这些实例,同时保证不同 Scope 之间不会互相干扰,NestJS 在 InstanceWrapper 中引入了 ContextId 对应的实例存储机制。

export interface InstancePerContext<T> {
  instance: T;
  isResolved?: boolean;  // 是否已经实例化
  isPending?: boolean;   // 是否正在实例化(用于防止循环依赖)
}

export class InstanceWrapper<T = any> {
    private readonly values = new WeakMap<ContextId, InstancePerContext<T>>();

    // 为特定 context 保存实例
    public setInstanceByContextId(
        contextId: ContextId,
        value: InstancePerContext<T>
    ) {
        this.values.set(contextId, value);
    }

    // 获取指定 context 下的实例
    public getInstanceByContextId(
        contextId: ContextId,
        inquirerId?: string
    ): InstancePerContext<T> {
        // Transient Provider 根据父 Provider 区分实例
        if (this.scope === Scope.TRANSIENT && inquirerId) {
          return this.getInstanceByInquirerId(contextId, inquirerId);
        }
        // Singleton、Request scope provider
        const instancePerContext = this.values.get(contextId);
        if (instancePerContext) return instancePerContext;

        // 请求阶段,如果依赖树不是完全静态,则创建新实例
        if (contextId !== STATIC_CONTEXT) {
          return this.cloneStaticInstance(contextId);
        }

        // 为防御性设计,实际使用中 STATIC_CONTEXT 初始化会保证 instancePerContext 永远存在
        return {
            instance: null as T,
            isResolved: true,
            isPending: false,
        };
    }

    // 创建请求上下文的新实例(浅拷贝)
    public cloneStaticInstance(contextId: ContextId): InstancePerContext<T> {
      const staticInstance = this.getInstanceByContextId(STATIC_CONTEXT);

      // 依赖树完全静态(Singleton) → 直接复用 static 实例
      if (this.isDependencyTreeStatic()) {
        return staticInstance;
      }

      const instancePerContext: InstancePerContext<T> = {
        ...staticInstance,
        instance: undefined!,
        isResolved: false,
        isPending: false,
      };

      if (this.isNewable()) {
        instancePerContext.instance = Object.create(this.metatype!.prototype);
      }

      this.setInstanceByContextId(contextId, instancePerContext);
      return instancePerContext;
    }
}

以上只是 singleton 和 request scope provider 的实例存储结构,而Transient Provider 较为特殊, 每个调用者(inquirer)都有自己的实例,因此单独存储:

export class InstanceWrapper<T = any> {
  private transientMap = new Map<string, WeakMap<ContextId, InstancePerContext<T>>>();

  public getInstanceByInquirerId(
    contextId: ContextId,
    inquirerId: string,
  ): InstancePerContext<T> {
    let collectionPerContext = this.transientMap.get(inquirerId);
    if (!collectionPerContext) {
      collectionPerContext = new WeakMap();
      this.transientMap.set(inquirerId, collectionPerContext);
    }
    const instancePerContext = collectionPerContext.get(contextId);
    return instancePerContext ?? this.cloneTransientInstance(contextId, inquirerId);
  }
}
  • 每个调用者(inquirerId)拥有独立的 WeakMap
  • WeakMap 再根据 ContextId 区分不同请求上下文
  • 确保 Transient Provider 不同调用者之间互不干扰

实例化流程:loadProvider 如何决定实例生成?

在 NestJS 中,同一个 Provider 可以在不同的上下文(Context)下拥有多个实例。这种上下文区分由 ContextId 实现,它标识了当前实例属于 全局启动阶段(Static Context) 还是 请求阶段(Request Context)

Nest 的依赖注入机制遵循“自底向上”的递归解析原则。每个待解析的 Provider 在实例化前都会依次经过 isStatic()、isInRequestScope() 等条件判断。

这种机制体现了 Request Scope 冒泡原则

  • 在启动阶段(Static Context):
    仅实例化依赖树完全静态(不包含任何 Request Scope Provider)的 Provider。

  • 在请求阶段(Request Context):
    当请求到来时,Nest 会根据当前 ContextId,为所有与请求相关的 Provider 创建独立实例。
    若某个 Singleton 依赖了 Request Scope Provider,它的类实例不会重新创建,但会在当前请求上下文中生成一个新的实例包装(InstancePerContext),从而正确关联请求作用域的依赖。

Tip: 依赖树由当前provider和其依赖组成,与上游provider无关。

export class Container {
  public async loadProvider(
    wrapper: InstanceWrapper,
    moduleRef: Module,
    contextId: ContextId = STATIC_CONTEXT, // 增加contextId参数
    inquirer?: InstanceWrapper
  ): Promise<void> {
    // ...
    await this.loadInstance(wrapper, collection, moduleRef, contextId, inquirer);
  }

  private async loadInstance<T>(
    wrapper: InstanceWrapper<T>,
    collection: Map<InjectionToken, InstanceWrapper>,
    moduleRef: Module,
    contextId: ContextId = STATIC_CONTEXT,
    inquirer?: InstanceWrapper
  ) {
    // 重点1:配合instanceWrapper,获取当前上下文下的实例,这里包含了所有的 scope
    const instanceHost = wrapper.getInstanceByContextId(contextId, inquirer?.id);

    // 如果当前上下文中该 provider 已经被实例化,则直接返回
    if (instanceHost.isResolved) {
      return;
    }

    await this.resolveConstructorParams(
      wrapper,
      moduleRef,
      wrapper.inject as InjectionToken[],
      async (instances: unknown[]) => {
        // 这是解析完构造函数参数后的回调;instances为构造函数参数的实例列表
        // 率先实例化的是最底层的provider, 其instances为空
        
        // 重点2:只有在isInContext 为 true 的时候才会实例化,最后给当前provider标记isResolved 并返回
        const isInContext =
          wrapper.isStatic(contextId, inquirer) ||
          wrapper.isInRequestScope(contextId, inquirer)
        if (isInContext) {
            // 源码中要区分一般provider 和 factory provider,也就是判断 inject 是否为空
            const instance = new (wrapper.metatype as Type<any>)(...instances);
            instanceHost.instance = instance;
        }
        instanceHost.isResolved = true;
        return instanceHost.instance;
      },
      contextId,
      inquirer
    );
  }
  public async resolveConstructorParams(wrapper, moduleRef, inject, callback) {
      // 伪代码:递归调用 loadProvider
      const params = this.getClassDependencies(wrapper)
      const instances = await Promise.all(params.map(async (param) => {
          const instanceHost = getInstanceByContextId(...)
          await loadProvider(...)
          return instanceHost.instance;
      }))
      callback(instances)
  }
}

instantiateClass 中的 isInContext 判断

在实际源码中,resolveConstructorParams的回调参数中调用了 instantiateClass(),其内部定义的isInContext决定了当前 Provider 是否要在当前上下文中被实例化:

const isInContext =
  wrapper.isStatic(contextId, inquirer) ||
  wrapper.isInRequestScope(contextId, inquirer) ||
  wrapper.isLazyTransient(contextId, inquirer) ||
  wrapper.isExplicitlyRequested(contextId, inquirer);

下面逐个解释这四个条件的语义:

函数 参数说明 返回 true 的典型场景 场景说明
isStatic 启动阶段(STATIC_CONTEXT),依赖树完全静态(无 Request) *? <- singleton[target] <- singleton/transient?

*? <- singleton - transient[target] <- singleton/transient?
硬性条件:依赖树不能有 request scope provider;

满足以下条件之一即可:
1. 自身是 singleton scope
2. 自身是 transient scope 但是被 singleton scope provider 调用。
isInRequestScope 请求阶段(contextId ≠ STATIC_CONTEXT,依赖树存在 Request scope provider 或自身是 Request scope provider request[target] <- *?

*? <- singleton[target] <- request

* <- transient[target] <- request
硬性条件:依赖树存在request scope provider;

满足以下条件之一即可:
1. 自身是 request scope
2. 自身是 singleton scope
3. 自身是 transient scope 且 被任意provider调用
isLazyTransient 请求阶段,依赖树静态,当前 provider 是 transient 且被 request scope provider 调用 *? <- request - transient[target] - transient/singleton? 硬性条件:依赖树不能有 request scope provider;

满足以下条件之一即可:
1. 自身是transient scope, 被 request scope provider调用
isExplicitlyRequested 请求阶段,当前 provider 显式被调用,或其调用者是 transient *? <- transient - transient/singleton[target] - transient/singleton?

@Injectable({ scope: Scope.TRANSIENT })
class MyService {};
const instance = moduleRef.get(MyService); // 这里 inquirer === MyService
硬性条件:依赖树不能有 request scope provider

满足以下条件之一即可:
1. 被transient provider 调用
2. 调用者是自身

小结:

1. Singleton scope provider 实例化规则:
  • 启动阶段:
    • 依赖树完全静态(无 Request Scope Provider)的 Singleton 会被立即实例化
  • 请求阶段:
    • 依赖树中包含 Request Scope Provider,则会生成与上下文(contextId)绑定的实例;
2. Request scope provider 实例化规则:
  • 启动阶段:
    • 不会被实例化(isStatic === false),因为此时请求上下文尚未存在。
  • 请求阶段:
    • 每个请求都会创建新的实例;
3. Transient scope provider 实例化规则:
  • 启动阶段:
    • 依赖树完全静态,且被 Singleton Provider 调用,会立即实例化;
  • 请求阶段:
    • transient 被 request scope provider 调用,生成新实例(绑定当前请求);
    • transient 被 transient provider 调用,生成新实例(与调用者隔离);
    • transient 被 singleton provider 调用,且依赖链下游有 request scope provider, 生成新实例;

注入 Request 对象

在 NestJS 中,请求对象(Request)不是自动全局可用的,而是通过依赖注入机制按请求上下文注入到 Provider 中。

@Inject(REQUEST) 的用法

Nest 提供了一个全局 token REQUEST,用来注入当前请求对象。用法示例:

import { Injectable, Inject, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';

@Injectable({ scope: Scope.REQUEST })
export class UserService {
  constructor(@Inject(REQUEST) private readonly request: any) {}

  getUserId(): string {
    return this.request.user?.id;
  }
}

实现原理

1. Request Provider 的注册

NestJS 在内部核心模块(InternalCoreModule)里,将 REQUEST 作为 Request Scope Provider 注册在全局 provider 列表里,这个 provider 被全局导出(exports),因此所有模块都可以注入 @Inject(REQUEST):

const noop = () => {}; // 默认是个空对象
export const requestProvider: Provider = {
  provide: REQUEST,
  scope: Scope.REQUEST,
  useFactory: noop, // 会在请求阶段被真正赋值
};
  • scope: Scope.REQUEST 表示它只能在请求阶段实例化。
  • 启动阶段只创建 provider metadata,但不会有 request 对象,此时request provider 是个空函数。

2. 请求阶段实例化与 request 注入

export class RouterExplorer {
  private getContextId<T extends Record<any, unknown> = any>(
    request: T,
    isTreeDurable: boolean,
  ): ContextId {
    // 这个request是 express返回的 req 对象
    const contextId = ContextIdFactory.getByRequest(request);
    if (!request[REQUEST_CONTEXT_ID as any]) {
      // 给request 增加 contextId 属性
      Object.defineProperty(request, REQUEST_CONTEXT_ID, {
        value: contextId,
        enumerable: false,
        writable: false,
        configurable: false,
      });

      const requestProviderValue = isTreeDurable
        ? contextId.payload
        : Object.assign(request, contextId.payload);
       // 最终得到 request 对象和 contextId,赋值给request provider
       this.container.registerRequestProvider(requestProviderValue, contextId);
    }
    return contextId;
  }
}
export class NestContainer {
  public registerRequestProvider<T = any>(request: T, contextId: ContextId) {
    const wrapper = this.internalCoreModule.getProviderByKey(REQUEST);
    wrapper.setInstanceByContextId(contextId, {
      instance: request,
      isResolved: true,
    });
  }
}

这是个很巧妙的实现,将request 对象作为全局 provider,和普通的provider一样,经过Nestjs 解析后生成对应 instanceWrapper 对象,它的valuesWeakMap<ContextId, InstancePerContext<T>>)属性用来存放具体的实例。每次请求进来时更新values就能拿到最新的Request对象。

小结:

  1. 若一个 singleton provider 注入了REQUEST,等同于依赖了一个 request scope provider,遵循request 冒泡原则。
  2. 根据第一点延伸可知:任何依赖 Request Provider 的 Provider,只会在请求阶段实例化,因此构造函数里访问 req 对象是安全的。启动阶段不会执行它们的构造函数。
  3. Req 对象和 contextId(非 STATIC_CONTEXT)强绑定,因此只有那些在请求阶段会实例化的provider 才可以拿到正确的req对象。

实践建议与思考

  1. 优先使用 Singleton

    • 对大多数服务和工具类,默认 Singleton 就足够,启动阶段就会实例化,无需担心请求上下文。
    • 仅当需要保存每个请求独立状态或临时实例时才考虑 Request/Transient。
  2. 谨慎使用 Request Scope

    • 每个请求都会生成新实例,频繁创建可能影响性能。
    • 避免在启动阶段访问 Request Scope Provider,否则会拿不到 req 对象。
  3. Transient Scope 要明确调用链

    • Transient Provider 会根据调用者生成独立实例,理解 inquirer 与依赖树关系是关键。
    • 设计短生命周期工具类时,考虑是否真的需要每次生成新实例。
  4. 考虑更轻量的上下文隔离方案 AsyncLocalStorage(ALS)

    • 如果只是想在任意位置访问当前请求的上下文信息(如 userId、traceId),可以采用 AsyncLocalStorage(ALS) 来管理请求级数据,它不依赖依赖注入系统,性能更高,也能在全局单例中安全访问请求上下文,但要注意,ALS 只隔离“数据”,并不会隔离“对象实例”,两者可结合使用。

前端对接 deepseek 流式实时回答效果

前言

佛祖保佑, 永无bug。Hello 大家好!我是海的对岸!

这个是项目接入AI,实现过程不难,记录一下。

先看效果

222.gif

开始

项目接入AI,本质调用第三方的接口使用第三方提供的api key,其实对接AI的部分最好是后端包一下,因为密钥啥的,还是保留在服务端更安全一点。这里就以前端视角,以deepseek 为例,走通下流程。

我当前的开发环境是vue3 + vite + element-plus + tailwindcss。

还有后续用到的第三方插件会在下文指出。

步骤

  1. 打开 deepseek 开放平台,注册账号,创建api key

3A13E673-B6F4-401C-9367-FCE2505AD20D.png

你创建的api key, 只有刚创建的时候是明文,记得要先找个地方保存一下,以后在开放平台查看,也查不到明文的,

Snipaste_2025-10-16_13-21-41.png

  1. 按照文档对接,deepseek 文档地址

Snipaste_2025-10-16_13-25-41.png

D3E1D276-F062-469E-AC82-D670262F1578.png

  1. 充钱

你没听错,调用deepseek的接口,需要先充值,不然接口走不通,返回 402,表示你没充钱

1F0E4E3E-03A4-43CE-87F8-6C8886F980FC.png

充钱,有个自定义选项,最低1元起充,个人可以冲个2元试试 Snipaste_2025-10-16_14-00-48.png

deepseek 比较人性化的一点是,你调用api接口,如果你传参数,传的content字段的值(就是你问的问题)是之前问过的,那么他是不扣费的,因此,我一直用重复的问题来调试对接,这个接口

主要说下步骤2

D3E1D276-F062-469E-AC82-D670262F1578.png

通过上图,我们知道,调用deepseek的接口,可以通过stream字段是否为true,设置成流式调用,也可以非流式,

流式输出和非流式输出的区别,对于设计前端交互体验非常重要。简单来说,这就像是等一道菜全部做完再上桌,还是做好一部分就先上一部分的区别。下面这个表格可以让你快速抓住核心差异。

特性 非流式输出 (stream=false) 流式输出 (stream=true)
数据返回方式 一次性返回完整响应 逐片段(chunk)实时返回
用户体验 需等待全部内容生成完成,页面可能处于加载状态 首字符响应极快,内容逐字或逐句出现,类似打字机效果
前端代码处理 简单,直接接收完整JSON结果 需处理数据流,解析每个返回的数据块
适用场景 响应内容较短或无需实时展示的任务,如简单问答、分类 长文本生成、对话聊天等需要即时反馈的场景
内存占用 服务端需生成完整内容,客户端需等待完整数据接收 逐步处理,对内存更友好
  • 需要用到的第三方包
需要安装的第三方包
"axios": "^1.12.2",
"highlight.js""11.10.0",  
"markdown-it""14.1.0",  
"nprogress""0.2.0",
  • 我们先做个非流式 核心就是调用deepseek api

Snipaste_2025-10-16_13-42-40.png

核心代码如下,基本说明也写在了注释中

// 简单封装了 axios
// src/fetch/ai.js
// 这个ai.js 大家可以直接拿来用,主要要把api key换成自己注册的就行

/**
 *  调用deepseek接口
 *  前端项目接入deepseek
 */

import axios from 'axios'
import Nprogress from 'nprogress'
import 'nprogress/nprogress.css'
import fetchEventSource from '@/fetch/fetchEventSource'

const apiKey = '替换成你自己注册的api key'; // 替换为实际的API Key
const deepSeekUrl = 'https://api.deepseek.com/v1/chat/completions'; // DeepSeek API地址

axios.defaults.withCredentials = true;
axios.defaults.headers['Content-Type'] = 'application/json;charset=UTF-8';
axios.defaults.headers.common['Authorization'] = `Bearer ${apiKey}`;

const instance = axios.create({
  withCredentials: true,
  headers: {
    'Content-Type': 'application/json;charset=UTF-8',
    'Authorization': `Bearer ${apiKey}`,
    'Accept': 'text/event-stream', // 根据后端返回类型调整
  },
});
//POST传参序列化
instance.interceptors.request.use(
  async (config) => {
    Nprogress.start()
    // ...
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)
//返回状态判断
instance.interceptors.response.use(
  (res) => {
    Nprogress.done()
    // ...
    return res
  },
  (error) => {
    return Promise.reject(error)
  }
)


export function postFetch(url, params) {
  return new Promise((resolve, reject) => {
    instance
      .post(url, params)
      .then(
        (response) => {
          resolve(response.data)
        },
        (err) => {
          reject(err)
        }
      )
      .catch((error) => {
        reject(error)
      })
  })
}
export function postJsonFetch(url, params, options) {
  // var params = params || {};
  return new Promise((resolve, reject) => {
    instance
      .post(url, params, options)
      .then(
        (response) => {
          resolve(response.data)
        },
        (err) => {
          reject(err)
        }
      )
      .catch((error) => {
        reject(error)
      })
  })
}
export function getFetch(url, params) {
  // var params = params || {};
  return new Promise((resolve, reject) => {
    axios
      .get(url, { params: params })
      .then(
        (response) => {
          resolve(response.data)
        },
        (err) => {
          reject(err)
        }
      )
      .catch((error) => {
        reject(error)
      })
  })
}
// export default axios;


export const fixeParam = () => {
  let dateFormate = Date.parse(new Date());
  return {
      dateFormate: dateFormate
  };
}

export default {
  // deepseek 非流式输出
  answerAI(params) {
    params = Object.assign(params, fixeParam())
    return postFetch(deepSeekUrl, params)
  },
  // deepseek 流式输出
  answerAI2(params) {
    const data = Object.assign(params, fixeParam());
    return new fetchEventSource(deepSeekUrl, {
      method: 'post',
      headers: {
        ['content-type']: 'application/json',
        'Authorization': `Bearer ${apiKey}`,
      },
      body: JSON.stringify(data),
      credentials: 'include'
    })
  },
  
};

然后去使用这个ai.js

src/components/myTextarea.vue

<!--
  自定义文本域组件
  用于在表单中输入多行文本,支持自动调整高度。
  样式中用到了 tailwindcss, 也不是此次重点,大家主要看功能
-->

<template>
  <div class="w-full">
    <el-input
      maxlength="300"
      show-word-limit
      type="textarea"
      class="textarea-class" 
      :autosize="{ minRows: 1, maxRows: 44 }" 
      placeholder="请输入题目"
      v-model="content" style="width: 100%">
    </el-input>
  
    <!-- resize="none" 禁用手动拉伸 -->

    <el-input
      maxlength="1000"
      show-word-limit
      type="textarea"
      class="textarea-class textarea-class-1"
      placeholder="请输入描述"
      v-model="context" style="width: 100%">
    </el-input>

    <el-button type="primary" class="w-full h-12" @click="sendMessage">问AI</el-button>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { ElMessage } from 'element-plus'
import ai from '@/fetch/ai.js'; // @这个引入符号的配置,这里就不说明了,不是此次重点

// 绑定的文本内容
const content = ref('');
const context = ref('');

const sendMessage = async () => {
  context.value = '';
  if (!content.value) {
    ElMessage.error(`不能为空`);
    return;
  }
  try {
    const response = await ai.answerAI({
      model: 'deepseek-chat',
      messages: [
        {
          role: 'user',
          content: content.value,
        }
      ]
    });
    // 处理返回的数据,例如将回复显示在页面上
    const reply = response.choices[0].message.content;
    context.value = reply;
    console.log(`AI回复:${reply}`);
  } catch (error) {
    // 处理错误情况
    console.log(`错误:${error.message}`);
  }
};
// 初始化时设置高度
onMounted(() => {

});
</script>

<style lang="scss" scoped>
.textarea-class {
  width: 100%;
  color: #333;
  font-family: "Alibaba PuHuiTi 3.0";
  font-size: 20px;
  font-style: normal;
  font-weight: 400;
  line-height: 180%;

  /* 36px */
  :deep(.el-textarea__inner) {
    display: flex; 
    padding: 16px 24px;
    min-height: 56px !important;
    // height: auto !important;
    flex-direction: column;
    justify-content: space-between;
    align-items: flex-start;
    align-self: stretch;
    border-radius: 12px;
    border: 1px solid #E9E9E9;
    background: rgba(0, 0, 0, 0.01);
    box-shadow: none;

    color: #333;
    font-family: "Alibaba PuHuiTi 3.0";
    font-size: 20px;
    font-style: normal;
    font-weight: 400;
    line-height: 100%;
    /* 18px */
  }

  :deep(.el-textarea__inner::placeholder) {
    color: #CCC;
    font-family: "Alibaba PuHuiTi 3.0";
    font-size: 20px;
    font-style: normal;
    font-weight: 400;
    line-height: 100%;
    /* 18px */
  }
}

.textarea-class-1{
  :deep(.el-textarea__inner) {
    height: 184px !important;
  }
}
.input2-class {
  height: 58px;
}
.input2-class :deep(.el-input__wrapper) {
  border-radius: 12px;
  border: 1px solid #E9E9E9;
  background: rgba(0, 0, 0, 0.01);
  box-shadow: none;
  padding: 1px 11px 1px 0px;
}

.input2-class :deep(.el-input__inner) {
  height: 68px;
  padding: 16px 24px;

  color: #000;
  font-family: "Alibaba PuHuiTi 3.0";
  font-size: 20px;
  font-style: normal;
  font-weight: 400;
  line-height: 180%;
  /* 36px */
}

.input2-class :deep(.el-input__inner::placeholder) {
  color: #CCC;
  font-family: "Alibaba PuHuiTi 3.0";
  font-size: 20px;
  font-style: normal;
  font-weight: 400;
  line-height: 100%;
  /* 18px */
}

</style>

效果如下:

05155A3E-09E6-4F6E-9E40-AF773BEF5E62.png

4DFB71EF-52D2-4C0F-9C06-9B59ED2DE66B.png

非流式输出,接口响应格式如下:

Snipaste_2025-10-16_14-06-38.png

  • 流式输出

就是文章开头看到的那张gif图的效果

222.gif

93C12AAC-213D-4DB5-A19A-D9C146CAC863.png

流式稍微复杂一点,用到了上图的fetchEventSource.js这个文件,这个文件主要实现了一个自定义的服务器发送事件(Server-Sent Events, SSE)客户端SSE 是一种服务器到客户端的单向通信协议,允许服务器向客户端推送实时数据,常用于实时通知、聊天应用等场景

// src/fetch/fetchEventSource.js

// 事件源请求
// 事件源请求函数,用于处理服务器发送的事件流
// 该函数接收一个URL和一个配置对象作为参数
// 配置对象包含headers(请求头)和其他fetch选项(如method、body等)
// 函数返回一个新的fetchEventSource实例

export default function fetchEventSource(url, { headers: inputHeaders, ...rest }) {
  this.headers = {
    accept: 'text/event-stream',
    ...inputHeaders
  }
  this.requestOptions = rest
  this.url = url
  this.create()
}
fetchEventSource.prototype = {
  fetchReader: null,
  fetchInstance: null,
  requestOptions: {},
  retryTimer: 0,
  retryInterval: 1000,
  ctrl: null,
  LastEventId: 'last-event-id',
  async create() {
    try {
      this.ctrl = new AbortController()
      this.fetchInstance = fetch(this.url, {
        headers: this.headers,
        ...this.requestOptions,
        signal: this.ctrl.signal
      })
      const response = await this.fetchInstance

      // 添加状态码检查
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      this.onopen && this.onopen(response)
      const reader = response.body && response.body.getReader()
      this.fetchReader = reader
      await getBytes(
        reader,
        getLines(
          getMessages(
            this.onmessage,
            (retry) => {
              this.retryInterval = retry
            },
            (id) => {
              if (id) {
                this.headers[this.LastEventId] = id
              } else {
                delete this.headers[this.LastEventId]
              }
            }
          )
        )
      )
      this.onclose && this.onclose()
      this.close()
    } catch (err) {
      // 修改错误处理,确保传递正确的错误信息
      const errorResponse = {
        status: err.name === 'TypeError' ? 'NETWORK_ERROR' : (await this.fetchInstance)?.status || 500,
        message: err.message
      }

      this.onerror && this.onerror(errorResponse)
      this.close()

      if (!this.ctrl.signal.aborted) {
        try {
          window.clearTimeout(this.retryTimer)
          // 不自动重试 500 错误
          if (errorResponse.status !== 500) {
            this.retryTimer = window.setTimeout(this.create.bind(this), this.retryInterval)
          }
        } catch (innerErr) {
          this.onerror && this.onerror(errorResponse)
          this.close()
        }
      }
    }
  },
  close() {
    window.clearTimeout(this.retryTimer)
    // 先触发 onclose 回调
    this.onclose && this.onclose()
    // 避免触发 onerror
    this.onerror = null
    // 最后才中止请求
    this.ctrl.abort()
  },
  onerror() {
    window.clearTimeout(this.retryTimer)
    this.ctrl.abort()
  }
}
async function getBytes(reader, onChunk) {
  let result
  while (!(result = await reader.read()).done) {
    onChunk(result.value)
  }
}

const ControlChars = {
  NewLine: 10,
  CarriageReturn: 13,
  Space: 32,
  Colon: 58
}
function getLines(onLine) {
  let buffer
  let position
  let fieldLength
  let discardTrailingNewline = false

  return function onChunk(arr) {
    if (buffer === undefined) {
      buffer = arr
      position = 0
      fieldLength = -1
    } else {
      buffer = concat(buffer, arr)
    }
    const bufLength = buffer.length
    let lineStart = 0
    while (position < bufLength) {
      if (discardTrailingNewline) {
        if (buffer[position] === ControlChars.NewLine) {
          lineStart = ++position
        }

        discardTrailingNewline = false
      }

      let lineEnd = -1
      for (; position < bufLength && lineEnd === -1; ++position) {
        switch (buffer[position]) {
          case ControlChars.Colon:
            if (fieldLength === -1) {
              fieldLength = position - lineStart
            }
            break
          case ControlChars.CarriageReturn:
            discardTrailingNewline = true
            break
          case ControlChars.NewLine:
            lineEnd = position
            break
        }
      }
      if (lineEnd === -1) {
        break
      }

      onLine(buffer.subarray(lineStart, lineEnd), fieldLength)
      lineStart = position
      fieldLength = -1
    }
    if (lineStart === bufLength) {
      buffer = undefined
    } else if (lineStart !== 0) {
      buffer = buffer.subarray(lineStart)
      position -= lineStart
    }
  }
}

function getMessages(onMessage, onRetry, onId) {
  let message = newMessage()
  const decoder = new TextDecoder()

  return function onLine(line, fieldLength) {
    if (line.length === 0) {
      onMessage === null || onMessage === void 0 ? void 0 : onMessage(message)
      message = newMessage()
    } else if (fieldLength > 0) {
      const field = decoder.decode(line.subarray(0, fieldLength))
      const valueOffset = fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1)
      const value = decoder.decode(line.subarray(valueOffset))
      switch (field) {
        case 'data':
          message.data = message.data ? message.data + '\n' + value : value
          break
        case 'event':
          message.event = value
          break
        case 'id':
          onId && onId((message.id = value))
          break
        case 'retry':
          const retry = parseInt(value, 10)
          message.retry = retry
          !isNaN(retry) && onRetry && onRetry(retry)
          break
      }
    }
  }
}
function concat(a, b) {
  const res = new Uint8Array(a.length + b.length)
  res.set(a)
  res.set(b, a.length)
  return res
}
function newMessage() {
  return {
    data: '',
    event: '',
    id: '',
    retry: undefined
  }
}

依旧是使用上面的 ai.js

然后是工具方法 untils.js

// src/views/aiAnswer/utils.js

// 渲染deepseek接口返回的markdown字符串,渲染成mardown展示在页面上

import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'

const md = MarkdownIt({
  highlight: function (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return '<pre><code class="hljs">' + hljs.highlight(str, { language: lang, ignoreIllegals: true }).value + '</code></pre>'
      } catch (__) {
        console.log(__)
      }
    }
    return '<pre><code  class="hljs">' + md.utils.escapeHtml(str) + '</code></pre>'
  }
})

// 渲染markdown
export const getRenderHtml = async (mdstring) => {
  if (!mdstring) return ''
  // console.log('mdstring', mdstring)
  // 是否包含其他指定符号,公式啥的 进行处理
  // if (满足其他条件) {
  //   const res = 处理 (mdstring)
  //   return res
  // }
  return md.render(mdstring)
}

// ... 其他工具方法
src/views/aiAnswer/index.vue

<!-- ai问答demo -->
<template>
  <div class="ai-answer">
    <div class="search-input">
      <el-input v-model="content" placeholder="请输入问题" clearable></el-input>
      <div class="submit-btn" @click="sendMessage">提问AI</div>
    </div>
    <template v-if="loading">
      <div class="no-data">
        ai思考中...
      </div>
    </template>
    <template v-else>
      <div class="no-data" v-if="!context">
        暂无回答
      </div>
      <div class="context" v-else v-html="context">
      </div>
    </template>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { ElMessage } from 'element-plus'
import ai from '@/fetch/ai';
import { getRenderHtml } from './utils.js'

const content = ref('');
const context = ref('');
let stremText = '';
const loading = ref(false);

const sendMessage = async () => {
  context.value = '';
  if (!content.value) {
    ElMessage.error(`不能为空`);
    return;
  }
  loading.value = true;
  try {
    console.log(1, new Date().getTime());
    const response = await ai.answerAI2({
      model: 'deepseek-chat',
      messages: [
        {
          role: 'user',
          content: content.value,
        }
      ],
      "stream": true, //  true 来使用流式输出
    });
    loading.value = false;
    console.log(2, new Date().getTime());
    console.log('response', response);

    response.onopen = () => console.log('0.连接已建立');
    response.onmessage = (res) => {
      console.log('1.收到消息', res);
      const data = res.data
      const parsedata = JSON.parse(data)
      // console.log('parsedata', parsedata);
      if (!parsedata.choices[0].finish_reason) {
        context.value += parsedata.choices[0].delta.content;
        console.log('stremText', stremText);
      } else if (parsedata.choices[0].finish_reason === 'stop') {
        parseAnswer(context.value)
      }
    };
    response.onerror = (err) => {
      console.log('2.连接错误', err);
    };
    response.onclose = () => {
      console.log('3.连接关闭');
    };
  } catch (error) {
    // loading.value = false;
    // 处理错误情况
    console.log(`错误:${error.message}`);
  }
};

const parseAnswer = async (answer) => {
  console.log('answer', answer);
  context.value = await getRenderHtml(context.value);
}

function removeKeysAndSymbols(jsonString) {
  // 去除所有的键(包括键名和后面的冒号)
  let withoutKeys = jsonString.replace(/"[^"]*"\s*:\s*/g, '')
  // 去除所有的符号(大括号、方括号、逗号)
  let withoutSymbols = withoutKeys.replace(/[{}\[\],]/g, '')
  // 去除多余的空格
  withoutSymbols = withoutSymbols.replace(/\s+/g, '')
  // 去除首尾的空字符串
  withoutSymbols = withoutSymbols.trim()
  return withoutSymbols
}
const filterText = (text) => {
  return removeKeysAndSymbols(text)
}
</script>

<style lang="scss" scoped>
.ai-answer {
  padding: 20px;
  background-color: #f5f5f5;
  border-radius: 10px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}
.search-input {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  height: 48px;
  padding: 10px 20px;
  border-radius: 12px;
  background: #FFF;

  .submit-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100px;
    height: 48px;
    padding: 10px 20px;
    border-radius: 12px;
    background: #384BF5;
    cursor: pointer;

    color: #FFF;
    font-family: "Alibaba PuHuiTi 3.0";
    font-size: 18px;
    font-style: normal;
    font-weight: 400;
    line-height: 100%; /* 18px */
  }
}
.no-data {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  height: 48px;
  padding: 10px 20px;
  border-radius: 12px;
  background: #FFF;
}
.context {
  // display: flex;
  // align-items: center;
  // justify-content: center;
  // gap: 10px;
  // height: 48px;
  padding: 10px 20px;
  border-radius: 12px;
  background: #FFF;
}

</style>

收尾 碎碎念

前端对接 deepseek 的 流式回答,流程走通,就是这样。

deepseek 比较人性化,你问的问题如果是之前已经问过的,那么它是不重复扣费的,因此,全篇文章,我都是 “你是谁”,“今天星期几”,这2个问题,来回问(奈何财力不足)

后续,比方说,把每次的对话记录存下来,回显出来,就和日常的做业务,已经没什么差别的,核心主要是看人家的官方文档,以及处理流式输出这一块。

这个就和搭建公司内部的脚手架那种,搭一次,除非又有新的需求,或者当前的功能不符合新业务场景,否则,是不会轻易改动的那种,没有crud那种改动的频繁。

好了,记录一下此次的对接deepseek过程。

❌