区块链钱包开发(十七)—— 构建密钥管理控制器(KeyringController)

概述

@metamask/keyring-controller 是 MetaMask 生态系统的核心组件,负责管理用户身份和密钥,处理各种签名操作。它是连接用户私钥和区块链交互的桥梁,确保私钥的安全存储和正确使用。

源码位置 : github.com/MetaMask/co...

主要功能

  • 密钥管理:安全存储和管理各种类型的密钥
  • 身份管理:管理用户账户和地址
  • 签名操作:处理消息、交易和用户操作的签名
  • 加密存储:使用强加密保护敏感数据
  • 并发控制:确保操作的安全性和一致性

总体架构图

%% KeyringController 架构图 graph TD %% ========== 核心组件 ========== A[KeyringController] --> B[Vault] A --> C[Keyring 管理器] A --> D[加密器] A --> E[消息系统] %% ========== Vault 结构 ========== B --> B1[加密状态存储] B1 -->|AES-256-GCM| B2["加密数据 (JSON)"] B2 --> B3["{ keyrings: [...], options: {...}, salt: '...' }"] %% ========== Keyring 类型 ========== C --> C1[HD Keyring] C --> C2[Simple Keyring] C --> C3[Hardware Keyring] C --> C4[Snap Keyring] C1 -->|BIP-32/BIP-44| C11["助记词 → 派生路径"] C2 -->|单私钥| C21["0x私钥直接存储"] C3 -->|USB/HID| C31["Ledger/Trezor"] %% ========== 加密流程 ========== D --> D1[PBKDF2] D --> D2[AES-GCM] D --> D3[盐值管理] %% ========== 外部交互 ========== E --> E1[UI 界面] E --> E2[其他Controller] E --> E3[DApp 请求] %% ========== 安全层 ========== F[安全机制] --> F1[双互斥锁] F --> F2[原子操作] F --> F3[内存清理] F --> F4[超时锁定] A --> F

核心概念

1. 密钥环 (Keyring)

密钥环是管理一组相关密钥的容器,每个密钥环负责特定类型的密钥管理:

typescript 复制代码
interface KeyringObject {
  accounts: string[];        // 关联的账户地址
  type: string;             // 密钥环类型
  metadata: KeyringMetadata; // 元数据信息
}

解析

  • accounts 存储该密钥环管理的所有区块链地址(如以太坊0x地址)
  • type 标识密钥环类型(如"HD Key Tree"、"Simple Key Pair"等),决定密钥生成方式
  • metadata 包含密钥环的扩展信息(如硬件钱包的设备ID、HD路径等)

2. Vault (保险库)

Vault 是加密存储所有密钥环数据的容器:

typescript 复制代码
interface KeyringControllerState {
  vault?: string;           // 加密的密钥环数据
  isUnlocked: boolean;      // 是否已解锁
  keyrings: KeyringObject[]; // 管理的密钥环列表
  encryptionKey?: string;   // 加密密钥(可选)
  encryptionSalt?: string;  // 加密盐值
}

解析

  • vault 是加密后的JSON字符串,包含所有密钥环的敏感数据(加密算法通常为AES-256-GCM)
  • isUnlocked 是核心安全标志,为false时禁止所有密钥操作
  • encryptionKey 只在内存中存在,由用户密码通过PBKDF2派生得到
  • encryptionSalt 确保相同密码每次派生的密钥不同

3. 加密器 (Encryptor)

负责数据的加密和解密操作:

typescript 复制代码
interface GenericEncryptor {
  encrypt(password: string, object: Json): Promise<string>;
  decrypt(password: string, encryptedString: string): Promise<unknown>;
  isVaultUpdated?(vault: string, params?: any): boolean;
}

解析

  • encrypt 方法执行流程:

    1. 使用PBKDF2从密码派生密钥(迭代次数>10,000)
    2. 生成随机IV(初始化向量)
    3. 用AES-GCM加密数据
    4. 返回IV+密文+认证标签的Base64组合
  • decrypt 会验证认证标签确保数据完整性

  • isVaultUpdated 用于检测加密参数变更(如迭代次数升级)

密钥环类型

1. HD 密钥环 (HD Key Tree)

基于 BIP-32/BIP-44 标准的层级确定性钱包:

typescript 复制代码
async addNewKeyring(type: KeyringTypes | string, opts?: unknown): Promise<KeyringMetadata> {
  this.#assertIsUnlocked()
  const metadata = await this.#persistOrRollback(async () => {
    const keyring = await this.#createKeyring(type, opts)
    this.#keyrings.push({ keyring, metadata: getDefaultKeyringMetadata() })
    return this.#getKeyringMetadata(keyring)
  })
  return metadata
}

解析

  • 底层使用bip32/bip39库实现
  • 首次创建时会生成24个助记词(BIP-39)
  • 每个账户对应不同的派生路径(如第一个账户是m/44'/60'/0'/0/0
  • 所有密钥由根种子派生,无需单独备份私钥

特点

  • 从单个种子生成无限数量的密钥对
  • 支持助记词备份和恢复
  • 符合 BIP-39 标准

2. 简单密钥环 (Simple Key Pair)

管理单个私钥的密钥环:

typescript 复制代码
// 导入私钥
async importAccountWithStrategy(strategy: 'privateKey'|'json', args: any[]): Promise<string> {
  this.#assertIsUnlocked()
  // 原子变更:导入失败不污染现有 keyrings
  return this.#persistOrRollback(async () => {
    let privateKey: Hex
    if (strategy === 'privateKey') {
      // 严格校验私钥格式和长度:避免垃圾输入污染 vault
      const [k] = args
      if (!k) throw new Error('Cannot import an empty key.')
      const prefixed = add0x(k)
      const bytes = hexToBytes(prefixed)
      if (!isValidPrivate(bytes) || getBinarySize(prefixed) !== 66) throw new Error('Invalid')
      privateKey = remove0x(prefixed)
    } else {
      // JSON 路径:依次兼容 EtherWallet/V3;解析私钥
      const [input, password] = args
      const wallet = tryEtherWalletThenV3(input, password)
      privateKey = bytesToHex(wallet.getPrivateKey())
    }

    // 以 Simple Key Pair 的独立 keyring 注入(与 HD 隔离)
    const simple = (await this.#newKeyring(KeyringTypes.simple, [privateKey])) as EthKeyring
    const accounts = await simple.getAccounts()
    return accounts[0] // 返回导入账户地址
  })
}

解析

  • 私钥直接存储在加密vault中
  • 适合导入MetaMask外部生成的密钥
  • 每个密钥环仅管理单个私钥
  • 与HD密钥环不同,不支持助记词恢复

特点

  • 适合导入现有私钥
  • 不支持助记词
  • 每个密钥环管理一个私钥

基本操作

1. 初始化密钥控制器

typescript 复制代码
import { KeyringController } from '@metamask/keyring-controller';

const keyringController = new KeyringController({
  messenger: restrictedMessenger,
  state: {
    vault: encryptedVaultData, // 可选的初始加密数据
  },
  cacheEncryptionKey: true, // 可选:缓存加密密钥以提高性能
});

解析

  • messenger 使用权限系统控制跨组件通信
  • state 支持服务端持久化存储(如IndexedDB)
  • cacheEncryptionKey 为true时,解锁后密钥保留在内存中

2. 创建新钱包

typescript 复制代码
// 创建新的 HD 钱包
async createNewVaultAndKeychain(password: string): Promise<void> {
  // 原子变更:仅当没有任何账户时创建全新 vault
  return this.#persistOrRollback(async () => {
    const existing = await this.#getAccountsFromKeyrings()
    if (!existing.length) {
      await this.#createNewVaultWithKeyring(password, { type: KeyringTypes.hd })
    }
  })
}

// 从助记词恢复钱包
async createNewVaultAndRestore(password: string, seed: Uint8Array): Promise<void> {
  // 用指定助记词创建全新 vault,并默认派生 1 个账号
  return this.#persistOrRollback(async () => {
    await this.#createNewVaultWithKeyring(password, {
      type: KeyringTypes.hd,
      opts: { mnemonic: seed, numberOfAccounts: 1 },
    })
  })
}

// 内部:创建并解锁新 vault + 第一个账户
async #createNewVaultWithKeyring(password: string, keyring: { type: string; opts?: unknown }) {
  this.#assertControllerMutexIsLocked() // 必须在互斥环境下修改关键状态
  if (typeof password !== 'string') throw new TypeError('WrongPasswordType')

  // 清理缓存的导出密钥,避免旧态污染新 vault
  this.update(s => { delete s.encryptionKey; delete s.encryptionSalt })
  this.#password = password

  await this.#clearKeyrings() // 清空内存中的 keyrings
  await this.#createKeyringWithFirstAccount(keyring.type, keyring.opts) // 创建首账号
  this.#setUnlocked() // 仅修改内存解锁态
}

解析

  • createNewVaultAndKeychain 内部流程:

    1. 调用bip39.generateMnemonic()
    2. 用密码加密助记词
    3. 初始化HD密钥环
  • 恢复时会验证助记词有效性(通过bip39.validateMnemonic

3. 解锁钱包

typescript 复制代码
async submitPassword(password: string): Promise<void> {
  // 解锁路径:使用用户密码解密 vault + 反序列化 keyrings
  const { newMetadata } = await this.#withRollback(async () => {
    const res = await this.#unlockKeyrings(password) // 可能返回新 metadata(老数据升级时)
    this.#setUnlocked()
    return res
  })
  try {
    // 若有新 metadata 或加密参数升级,解锁后写回升级后的 vault
    if (newMetadata || this.#isNewEncryptionAvailable()) await this.#updateVault()
  } catch {}
}

async submitEncryptionKey(encryptionKey: string, encryptionSalt?: string): Promise<void> {
  // 解锁路径:使用缓存导出密钥(无须密码)解密 vault
  const { newMetadata } = await this.#withRollback(async () => {
    const res = await this.#unlockKeyrings(undefined, encryptionKey, encryptionSalt)
    this.#setUnlocked()
    return res
  })
  try { if (newMetadata) await this.#updateVault() } catch {}
}

4. 账户管理

typescript 复制代码
// 获取所有账户
async getAccounts(): Promise<string[]> {
  this.#assertIsUnlocked()
  // 读取展示层 keyrings 合并地址,不触碰敏感数据
  return this.state.keyrings.reduce<string[]>((acc, kr) => acc.concat(kr.accounts), [])
}

// 移除账户
async removeAccount(address: string): Promise<void> {
  this.#assertIsUnlocked()
  // 原子变更:禁止删除"主密钥环"中的最后一个账号
  await this.#persistOrRollback(async () => {
    const keyring = await this.getKeyringForAccount(address)
    const idx = this.state.keyrings.findIndex(kr => kr.accounts.includes(address))
    const isPrimary = idx === 0
    const onlyOne = (await keyring.getAccounts()).length === 1
    if (isPrimary && onlyOne) throw new Error('LastAccountInPrimaryKeyring')
    if (!keyring.removeAccount) throw new Error('UnsupportedRemoveAccount')
    keyring.removeAccount(address as Hex)
    if (onlyOne) await this.#removeEmptyKeyrings() // 空 keyring 清理
  })
  // 通知订阅者(UI 等)
  this.messagingSystem.publish('KeyringController:accountRemoved', address)
}

5. 导出私钥/助记词/加密密钥

typescript 复制代码
async exportAccount(password: string, address: string): Promise<string> {
  await this.verifyPassword(password) // 强校验,避免被劫持导出
  const keyring = await this.getKeyringForAccount(address)
  if (!keyring.exportAccount) throw new Error('UnsupportedExportAccount')
  return keyring.exportAccount(ethNormalize(address) as Hex)
}

async exportSeedPhrase(password: string, keyringId?: string): Promise<Uint8Array> {
  this.#assertIsUnlocked()
  await this.verifyPassword(password) // 保护助记词导出
  const kr = this.#getKeyringByIdOrDefault(keyringId)
  if (!kr) throw new Error('Keyring not found')
  if (kr.type !== KeyringTypes.hd) throw new Error('UnsupportedVerifySeedPhrase')
  assertHasUint8ArrayMnemonic(kr)
  return kr.mnemonic
}

async exportEncryptionKey(): Promise<string> {
  this.#assertIsUnlocked()
  // 仅在 cacheEncryptionKey=true 时可用;避免空态返回
  return this.#withControllerLock(async () => {
    const { encryptionKey } = this.state
    if (!encryptionKey) throw new Error('EncryptionKeyNotSet')
    return encryptionKey
  })
}

高级功能

1. 消息签名

typescript 复制代码
// 签名消息
async signMessage({ from, data }: { from: string; data: string }): Promise<string> {
  this.#assertIsUnlocked()
  if (!data) throw new Error("Can't sign an empty message")
  const keyring = await this.getKeyringForAccount(ethNormalize(from) as Hex)
  if (!keyring.signMessage) throw new Error('UnsupportedSignMessage')
  return keyring.signMessage(ethNormalize(from) as Hex, data) // ECDSA/secp256k1
}

// 签名个人消息
async signPersonalMessage({ from, data }: { from: string; data: string }): Promise<string> {
  this.#assertIsUnlocked()
  const keyring = await this.getKeyringForAccount(ethNormalize(from) as Hex)
  if (!keyring.signPersonalMessage) throw new Error('UnsupportedSignPersonalMessage')
  // 统一十六进制格式,兼容 EVM
  return keyring.signPersonalMessage(ethNormalize(from) as Hex, normalize(data) as Hex)
}

// 签名EIP-712消息
async signTypedMessage(messageParams: TypedMessageParams, version: 'V1'|'V3'|'V4'): Promise<string> {
  this.#assertIsUnlocked()
  if (!['V1','V3','V4'].includes(version)) throw new Error(`Unexpected version`)
  const from = ethNormalize(messageParams.from) as Hex
  const keyring = await this.getKeyringForAccount(from)
  if (!keyring.signTypedData) throw new Error('UnsupportedSignTypedMessage')
  // V3/V4 需要结构化数据;字符串时先 JSON.parse
  const data = version !== 'V1' && typeof messageParams.data === 'string'
    ? JSON.parse(messageParams.data) : messageParams.data
  return keyring.signTypedData(from, data, { version }) // EIP-712 规范
}

2. 交易签名

typescript 复制代码
async signTransaction(tx: TypedTransaction, from: string, opts?: Record<string, unknown>) {
  this.#assertIsUnlocked()
  const address = ethNormalize(from) as Hex
  const keyring = await this.getKeyringForAccount(address)
  if (!keyring.signTransaction) throw new Error('UnsupportedSignTransaction')
  // 支持 EIP-2718(EIP-1559/2930/Legacy),由 keyring 完成 r,s,v
  return keyring.signTransaction(address, tx, opts)
}

解析

  • 支持所有EIP-2718交易类型(Legacy/EIP-1559/EIP-2930)

  • 签名流程:

    1. 根据fromAddress找到对应密钥环
    2. 序列化交易数据
    3. 用私钥生成ECDSA签名(secp256k1曲线)
    4. 返回RLP编码的签名交易

3. 用户操作签名 (EIP-4337)

typescript 复制代码
// 准备用户操作
async prepareUserOperation(from: string, txs: EthBaseTransaction[], ctx: KeyringExecutionContext) {
  this.#assertIsUnlocked()
  const addr = ethNormalize(from) as Hex
  const keyring = await this.getKeyringForAccount(addr)
  if (!keyring.prepareUserOperation) throw new Error('UnsupportedPrepareUserOperation')
  // 生成 pseudo-UserOperation(打包多交易),供 bundler/PM 使用
  return keyring.prepareUserOperation(addr, txs, ctx)
}
// 修补用户操作
async patchUserOperation(from: string, userOp: EthUserOperation, ctx: KeyringExecutionContext) {
  this.#assertIsUnlocked()
  const addr = ethNormalize(from) as Hex
  const keyring = await this.getKeyringForAccount(addr)
  if (!keyring.patchUserOperation) throw new Error('UnsupportedPatchUserOperation')
  // 例如增补 paymasterAndData
  return keyring.patchUserOperation(addr, userOp, ctx)
}
// 签名用户操作
async signUserOperation(from: string, userOp: EthUserOperation, ctx: KeyringExecutionContext) {
  this.#assertIsUnlocked()
  const addr = ethNormalize(from) as Hex
  const keyring = await this.getKeyringForAccount(addr)
  if (!keyring.signUserOperation) throw new Error('UnsupportedSignUserOperation')
  // 计算 userOpHash 并签名,兼容 ERC-1271 验证
  return keyring.signUserOperation(addr, userOp, ctx)
}

解析

  • 专为EIP-4337账户抽象设计
  • 签名前会计算userOpHash(包含所有字段的Keccak哈希)
  • 最终签名兼容ERC-1271验证标准

4. 密码管理

typescript 复制代码
async verifyPassword(password: string) {
  // 仅验证当前 vault 能否被解密;不修改任何状态
  if (!this.state.vault) throw new Error('VaultError')
  await this.#encryptor.decrypt(password, this.state.vault)
}

async changePassword(password: string) {
  this.#assertIsUnlocked()
  if (this.#password === password) return // 幂等
  // 原子变更:仅更新内存密码和缓存密钥;写回由 #persistOrRollback 触发
  await this.#persistOrRollback(async () => {
    assertIsValidPassword(password)
    this.#password = password
    if (this.#cacheEncryptionKey) {
      this.update(s => { delete s.encryptionKey; delete s.encryptionSalt }) // 强制下次重加密
    }
  })
}

async setLocked() {
  this.#assertIsUnlocked()
  // 原子变更:清理内存中的敏感对象并发布锁定事件
  return this.#withRollback(async () => {
    this.#unsubscribeFromQRKeyringsEvents()
    this.#password = undefined
    await this.#clearKeyrings() // 调用 destroy 清理事件/桥接 iframe 等
    this.update(s => {
      s.isUnlocked = false; s.keyrings = []
      delete s.encryptionKey; delete s.encryptionSalt
    })
    this.messagingSystem.publish('KeyringController:lock')
  })
}

安全机制

1. 并发控制

密钥控制器使用双重互斥锁确保操作安全:

typescript 复制代码
// 控制器操作锁 - 保护所有状态变更操作
readonly #controllerOperationMutex = new Mutex();

// Vault 操作锁 - 保护加密存储操作
readonly #vaultOperationMutex = new Mutex();

2. 原子操作

所有状态变更操作都是原子的,支持回滚:

typescript 复制代码
async #persistOrRollback(callback) {
  // 变更前后做 session 快照(keyrings+password);有变更才写回 vault
  return this.#withRollback(async ({ releaseLock }) => {
    const oldState = JSON.stringify(await this.#getSessionState())
    const result = await callback({ releaseLock })
    const newState = JSON.stringify(await this.#getSessionState())
    if (!isEqual(oldState, newState)) await this.#updateVault() // 统一持久化出口
    return result
  })
}

async #withRollback(callback) {
  // 控制器级互斥 + 异常回滚(恢复 keyrings/password 快照)
  return this.#withControllerLock(async ({ releaseLock }) => {
    const snapshot = await this.#getSerializedKeyrings()
    const savedPwd = this.#password
    try {
      return await callback({ releaseLock })
    } catch (e) {
      this.#password = savedPwd
      await this.#restoreSerializedKeyrings(snapshot)
      throw e
    }
  })
}

3. 状态验证

typescript 复制代码
// 验证解锁状态
#assertIsUnlocked() {
  if (!this.state.isUnlocked) {
    throw new Error('Controller is locked');
  }
}

// 验证互斥锁状态
#assertControllerMutexIsLocked() {
  if (!this.#controllerOperationMutex.isLocked()) {
    throw new Error('Controller lock required');
  }
}

4. 重复账户检查

typescript 复制代码
// 确保没有重复账户
async #assertNoDuplicateAccounts(additionalKeyrings = []) {
  const accounts = await this.#getAccountsFromKeyrings(additionalKeyrings);
  if (new Set(accounts).size !== accounts.length) {
    throw new Error('Duplicate accounts found');
  }
}

实际应用示例

1. 完整的钱包初始化流程

typescript 复制代码
class WalletManager {
  private keyringController: KeyringController;

  async initializeWallet(password: string, seedPhrase?: Uint8Array) {
    try {
      if (seedPhrase) {
        // 从助记词恢复
        await this.keyringController.createNewVaultAndRestore(password, seedPhrase);
      } else {
        // 创建新钱包
        await this.keyringController.createNewVaultAndKeychain(password);
      }
      
      // 解锁钱包
      await this.keyringController.submitPassword(password);
      
      // 获取账户
      const accounts = await this.keyringController.getAccounts();
      
      return {
        success: true,
        accounts,
        isNewWallet: !seedPhrase
      };
    } catch (error) {
      return {
        success: false,
        error: error.message
      };
    }
  }
}

2. 多签名钱包集成

typescript 复制代码
class MultiSigWallet {
  async signTransaction(transaction: TypedTransaction, signers: string[]) {
    const signatures = [];
    
    for (const signer of signers) {
      const signature = await this.keyringController.signTransaction(
        transaction,
        signer
      );
      signatures.push(signature);
    }
    
    return this.combineSignatures(transaction, signatures);
  }
}

3. 硬件钱包集成

typescript 复制代码
class HardwareWalletManager {
  async connectLedger() {
    // 添加 Ledger 密钥环
    const ledgerKeyring = await this.keyringController.addNewKeyring(
      KeyringTypes.ledger
    );
    
    // 连接设备并获取账户
    const accounts = await this.keyringController.withKeyring(
      { type: KeyringTypes.ledger },
      async ({ keyring }) => {
        return await keyring.getAccounts();
      }
    );
    
    return accounts;
  }
}

4. 消息签名服务

typescript 复制代码
class MessageSigningService {
  async signEIP712Message(
    from: string,
    domain: any,
    types: any,
    message: any
  ) {
    const typedData = {
      types,
      primaryType: 'Message',
      domain,
      message
    };
    
    return await this.keyringController.signTypedMessage(
      {
        from,
        data: typedData
      },
      SignTypedDataVersion.V4
    );
  }
}

最佳实践

1. 错误处理

typescript 复制代码
async function safeKeyringOperation(operation: () => Promise<any>) {
  try {
    return await operation();
  } catch (error) {
    if (error.message.includes('Controller is locked')) {
      // 处理锁定状态
      throw new Error('Wallet is locked. Please unlock first.');
    } else if (error.message.includes('Wrong password')) {
      // 处理密码错误
      throw new Error('Incorrect password provided.');
    } else {
      // 处理其他错误
      console.error('Keyring operation failed:', error);
      throw error;
    }
  }
}

2. 状态管理

typescript 复制代码
class WalletStateManager {
  private keyringController: KeyringController;
  
  async getWalletState() {
    const state = this.keyringController.state;
    const accounts = await this.keyringController.getAccounts();
    
    return {
      isUnlocked: state.isUnlocked,
      accounts,
      keyringCount: state.keyrings.length,
      hasVault: !!state.vault
    };
  }
  
  async waitForUnlock(): Promise<void> {
    return new Promise((resolve) => {
      const checkUnlock = () => {
        if (this.keyringController.isUnlocked()) {
          resolve();
        } else {
          setTimeout(checkUnlock, 100);
        }
      };
      checkUnlock();
    });
  }
}

3. 性能优化

typescript 复制代码
class OptimizedKeyringController {
  private encryptionKeyCache: string | null = null;
  
  async unlockWithCachedKey(password: string) {
    // 首次解锁时缓存加密密钥
    if (!this.encryptionKeyCache) {
      await this.keyringController.submitPassword(password);
      this.encryptionKeyCache = await this.keyringController.exportEncryptionKey();
    } else {
      // 后续解锁使用缓存的密钥
      await this.keyringController.submitEncryptionKey(this.encryptionKeyCache);
    }
  }
}

4. 安全措施

  1. 密码强度:确保用户使用强密码
  2. 定期备份:定期导出助记词和加密密钥
  3. 环境隔离:在生产环境中隔离敏感操作
  4. 审计日志:记录所有关键操作
  5. 超时机制:实现自动锁定机制
typescript 复制代码
class SecurityManager {
  private autoLockTimer: NodeJS.Timeout | null = null;
  private readonly AUTO_LOCK_DELAY = 5 * 60 * 1000; // 5分钟
  
  startAutoLockTimer() {
    this.autoLockTimer = setTimeout(async () => {
      await this.keyringController.setLocked();
    }, this.AUTO_LOCK_DELAY);
  }
  
  resetAutoLockTimer() {
    if (this.autoLockTimer) {
      clearTimeout(this.autoLockTimer);
    }
    this.startAutoLockTimer();
  }
}

学习交流请添加vx: gh313061

下期预告:构建批准控制器(ApprovalController)

相关推荐
碎像22 分钟前
uni-app实战教程 从0到1开发 画图软件 (学会画图)
前端·javascript·css·程序人生·uni-app
Hilaku39 分钟前
从“高级”到“资深”,我卡了两年和我的思考
前端·javascript·面试
WebInfra1 小时前
Rsdoctor 1.2 发布:打包产物体积一目了然
前端·javascript·github
秋天的一阵风1 小时前
😈 藏在对象里的 “无限套娃”?教你一眼识破循环引用诡计!
前端·javascript·面试
用户1409508112802 小时前
原型链、闭包、事件循环等概念,通过手写代码题验证理解深度
前端·javascript
汪子熙2 小时前
错误消息 Could not find Nx modules in this workspace 的解决办法
前端·javascript
前端美少女战士2 小时前
post方法下载文件,需做哪些特殊处理
javascript·react.js
Juchecar2 小时前
Node.js 项目关于使用 ts-node 的建议(附ERR_UNKNOWN_FILE_EXTENSION异常解决办法)
javascript
Spider_Man2 小时前
和AI畅聊不掉线:本地部署LLM聊天界面全攻略
javascript·llm·deepseek
枫叶是圆的2 小时前
纯CSS+JS制作抽奖大转盘
前端·javascript·css·html·css3