Electron 使用 SQLite + Prisma 打包最佳实践

背景

  1. 开始前先说一下这个Prisma,固然很好用,在传统的Node.js里面可以直接帮我们操作数据库,而无需去关注SQL的编写。

  2. 但是Electron的环境下,Prisma是运行在主进程Main中的,开发环境没啥问题但是呢,一到打包的时候就不行了。

  3. 原因是Prisma使用运行时的Client,意思是会在node_modules/.prisma/client(pnpm则会是node_modules/.pnpm/@prisma+client@6.8.2_prisma_xxxx/node_modules/.prisma/client)文件夹里面生成真实的查询器。

  4. 而我们通过webpack/vite进行静态分析的过程中,没办法找到这个动态生成的查询器,也就导致了其未被打包进入Electron里面去。

  5. 目前我的解决方案是通过修改其动态生成的client的位置,通过package软链接依赖,实际使用时使用这个软链接依赖,让静态分析器能够找到这个Client。

  6. 从而,让Client能够正确被打包进项目中。说白了就是让这个动态生成的Client作为代码的一部分。在打包的命令里面需要添加npx prisma generate &&,这样执行命令就会先生成最新的Client。
    image

  7. 找了一圈,AI也问了,Github也翻了,文档也看了,网上关于修改extraResoures/asarUnpack的配置,都不行,默认存放Client的位置都没法被打包进去。

  8. 都会提示:A JavaScript error occurred in the main process Error: Cannot find module '@prisma/client'。

  9. 吐槽一下我也有尝试更新至v7版本(我现在是v6.8.2),v7去掉了schema url的配置,又要手动导入adapter麻烦的要死,能不能开箱即用啊。

  10. 下面给出我的解决办法。

    • 修改 schema.prisma 中的 output 路径为 ../src/generated/client
    • package.json 中创建软链接:"db": "link:src\\generated\\client"
    • 通过 import { PrismaClient } from 'db' 引用
    • 在 prisma.ts(/src/main/api/config/prisma.ts) 中封装复杂的引擎路径查找逻辑

下面是详细的说明:

Electron + Prisma + pnpm 打包解决方案实践总结

问题背景

在使用 Electron + Prisma + pnpm 的项目中,打包后经常遇到以下错误:

[Window Title]
Error

[Main Instruction]
A JavaScript error occurred in the main process

[Content]
Uncaught Exception:
Error: Cannot find module '@prisma/client'
Require stack:
- C:\Users\Administrator\AppData\Local\Programs\xxx\resources\app.asar\out\main\index.js
- 
    at Module._resolveFilename (node:internal/modules/cjs/loader:1219:15)
    at s._resolveFilename (node:electron/js2c/browser_init:2:124514)
    at Module._load (node:internal/modules/cjs/loader:1050:27)
    at c._load (node:electron/js2c/node_init:2:16955)
    at Module.require (node:internal/modules/cjs/loader:1305:19)
    at require (node:internal/modules/helpers:182:18)
    at Object.<anonymous> (C:\Users\Administrator\AppData\Local\Programs\xxx\resources\app.asar\out\main\index.js:10:16)
    at Module._compile (node:internal/modules/cjs/loader:1544:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1629:10)
    at Module.load (node:internal/modules/cjs/loader:1282:32)

[确定]

核心问题分析

Prisma 在 Electron + ASAR 环境下存在两个关键问题:

  1. 模块解析问题:当 @prisma/client 被打包到 app.asar 中时,其内部的 require('.prisma/client/default') 会在 ASAR 内查找,导致模块找不到
  2. 引擎加载问题:即使使用 asarUnpack 解包文件,Prisma 也无法自动感知引擎的新位置,必须通过 binaryPath 显式指定
  3. pnpm 特殊性:pnpm 使用符号链接和 .pnpm 目录管理依赖,导致标准的 node_modules/.prisma 路径实际上不存在

我们的解决方案

经过实践,我们采用了以下方案,已在生产环境稳定运行:

1. 修改 Prisma Client 输出路径

文件: prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
  output   = "../src/generated/client"  // 自定义输出到 src 目录
  binaryTargets = ["native", "windows"]
  previewFeatures = ["driverAdapters"]
}

datasource db {
  provider = "sqlite"
  url      = "file:./app.db"
}

关键点

  • 将 Prisma Client 生成到 src/generated/client 目录
  • 这样可以确保生成的代码被打包到 out 目录中
  • 避免了 pnpm 符号链接带来的路径问题

2. 创建 package.json 软链接

文件: package.json

{
  "dependencies": {
    "@prisma/client": "^6.8.2",
    "db": "link:src\\generated\\client", // 创建软链接
    "prisma": "^6.8.2"
  }
}

关键点

  • 使用 link: 协议创建本地包链接
  • 允许通过 import { PrismaClient } from 'db' 引用
  • 保持代码的简洁性

3. 封装引擎路径查找逻辑

文件: src/main/api/config/prisma.ts

import { PrismaClient } from 'db'
import { app } from 'electron'
import path from 'path'
import { existsSync } from 'fs'
import { dbPath, isDev } from '../../config/database-path'
import { isDevelopment } from '../../config/env'
import logger from './logger'

/**
 * 获取平台名称(包括 ARM64 检测)
 */
function getPlatformName(): string {
  const isDarwin = process.platform === 'darwin'
  if (isDarwin && process.arch === 'arm64') {
    return process.platform + 'Arm64'
  }
  return process.platform
}

/**
 * 平台到引擎文件的映射
 */
const platformToExecutables: Record<string, { queryEngine: string; migrationEngine: string }> = {
  win32: {
    queryEngine: 'query_engine-windows.dll.node',
    migrationEngine: 'migration-engine-windows.exe'
  },
  linux: {
    queryEngine: 'libquery_engine-debian-openssl-1.1.x.so.node',
    migrationEngine: 'migration-engine-debian-openssl-1.1.x'
  },
  darwin: {
    queryEngine: 'libquery_engine-darwin.dylib.node',
    migrationEngine: 'migration-engine-darwin'
  },
  darwinArm64: {
    queryEngine: 'libquery_engine-darwin-arm64.dylib.node',
    migrationEngine: 'migration-engine-darwin-arm64'
  }
}

/**
 * 获取查询引擎库文件路径
 * 在打包环境中,需要在多个可能的位置查找引擎文件
 */
function getQueryEnginePath(): string | null {
  try {
    // 开发环境让 Prisma 自动找到引擎
    if (!app || isDevelopment) {
      return null
    }

    const platformName = getPlatformName()
    const engines = platformToExecutables[platformName]

    if (!engines) {
      logger.warn(`不支持的平台: ${platformName}`)
      return null
    }

    // 获取应用路径
    const appPath = app.getAppPath()
    const isAsarPackaged = appPath.includes('app.asar')

    let extraResourcesPath: string
    if (isAsarPackaged) {
      // 如果是打包的 asar 文件
      extraResourcesPath = path.join(path.dirname(appPath), '..')
    } else {
      // 如果是解压后的目录
      extraResourcesPath = path.dirname(appPath)
    }

    // 构建查询引擎路径 - 多个可能的位置
    const possiblePaths = [
      // extraResources 中的路径
      path.join(extraResourcesPath, 'node_modules', '@prisma', 'engines', engines.queryEngine),

      // app.asar.unpacked 中的路径
      path.join(
        extraResourcesPath,
        'app.asar.unpacked',
        'node_modules',
        '@prisma',
        'engines',
        engines.queryEngine
      ),

      // resources 目录中的路径
      path.join(process.resourcesPath, 'node_modules', '@prisma', 'engines', engines.queryEngine),

      // 应用目录中的路径
      path.join(
        appPath.replace('app.asar', 'app.asar.unpacked'),
        'node_modules',
        '@prisma',
        'engines',
        engines.queryEngine
      ),

      // 备用路径
      path.join(
        path.dirname(extraResourcesPath),
        'node_modules',
        '@prisma',
        'engines',
        engines.queryEngine
      )
    ]

    // 查找第一个存在的引擎文件
    for (const enginePath of possiblePaths) {
      if (existsSync(enginePath)) {
        logger.info(`找到 Prisma 查询引擎: ${enginePath}`)
        return enginePath
      }
    }

    logger.warn(`未找到 Prisma 查询引擎文件: ${engines.queryEngine}`)
    logger.warn('检查的路径:')
    possiblePaths.forEach((p) => logger.warn(`  - ${p}`))

    return null
  } catch (error) {
    logger.error('获取查询引擎路径时出错:', error)
    return null
  }
}

/**
 * 创建配置好的 PrismaClient 实例
 */
export function createPrismaClient(options?: any): PrismaClient {
  const enginePath = getQueryEnginePath()

  // 动态设置数据库URL
  const databaseUrl = isDev ? 'file:./app.db' : `file:${dbPath}`

  logger.info(`使用的数据库 URL: ${databaseUrl}`)
  logger.info(`数据库文件路径: ${dbPath}`)
  logger.info(`查询引擎路径: ${enginePath}`)

  const prismaOptions: any = {
    ...options,
    datasources: {
      db: {
        url: databaseUrl
      }
    },
    log: isDevelopment ? ['warn', 'error'] : ['error']
  }

  // 如果找到了引擎路径,则配置它
  if (enginePath) {
    prismaOptions.__internal = {
      ...options?.__internal,
      engine: {
        ...options?.__internal?.engine,
        binaryPath: enginePath
      }
    }
  }

  return new PrismaClient(prismaOptions)
}

// 导出默认配置的客户端实例
export const prisma = createPrismaClient()

// 为了向后兼容,也导出 PrismaClient 类型
export { PrismaClient } from 'db'
/** database-path.ts */
import path from 'path'
import { app } from 'electron'
import { isDevelopment } from './env'

export const isDev = isDevelopment

/**
 * 获取数据库文件路径
 * 生产环境:放在应用数据目录
 * 开发环境:放在项目根目录
 */
export const getDatabasePath = (): string => {
  if (isDev) {
    // 开发环境:使用项目根目录
    return path.join(process.cwd(), 'prisma', 'app.db')
  } else {
    // 生产环境:使用应用数据目录
    const userDataPath = app.getPath('userData')
    return path.join(userDataPath, 'app.db')
  }
}

export const dbPath = getDatabasePath()

关键点

  • 封装了跨平台的引擎文件名映射
  • 实现了多路径查找逻辑,提高容错性
  • 通过 __internal.engine.binaryPath 显式指定引擎位置
  • 开发环境和生产环境自动切换

4. 配置 electron-builder

文件: electron-builder.yml

appId: com.electron.app
productName: XXX
directories:
  buildResources: build
  output: dist
files:
  - out/**/*
  - prisma/**/*
  - '!prisma/app.db'
  - '!**/.vscode/*'
  - '!src/*'
  - '!electron.vite.config.{js,ts,mjs,cjs}'
  - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
  - '!{.env*,.npmrc,pnpm-lock.yaml}'
  - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
  - '!mock/**/*'
  - '!demo/**/*'
  - '!docs/**/*'
  - '!logs/**/*'
  - '!scripts/**/*'
  - '!build/**/*'
  # 排除不需要的 Prisma 引擎文件
  - '!**/node_modules/@prisma/engines/introspection-engine*'
  - '!**/node_modules/@prisma/engines/migration-engine*'
  - '!**/node_modules/@prisma/engines/prisma-fmt*'
  - '!**/node_modules/prisma/**/*.mjs'
  - '!**/.*'

asar: true
asarUnpack:
  - 'prisma/**/*' # 解包 prisma schema 文件

win:
  executableName: 'XXX'
nsis:
  artifactName: ${name}-${version}-setup.${ext}
  shortcutName: ${productName}
  uninstallDisplayName: ${productName}
  createDesktopShortcut: always

关键点

  • 使用 asar: true 启用 ASAR 打包
  • 通过 asarUnpack 解包 prisma 目录(包含 schema 文件)
  • 排除不需要的 Prisma 引擎文件,减小打包体积
  • 不需要配置 extraResources,因为生成的 client 已经在 out 目录中

方案优势

解决 pnpm 问题:通过自定义输出路径,完全避开 pnpm 的符号链接机制 ✅ 路径可控:生成的代码在 src 目录中,打包后在 out 目录中,路径清晰可预测✅ 跨平台支持:封装了平台检测和引擎文件映射,支持 Windows、macOS、Linux ✅ 容错性强:多路径查找逻辑,提高了在不同打包环境下的兼容性✅ 开发体验好:通过软链接保持代码简洁,import from 'db''@prisma/client' 更短✅ 生产稳定:已在实际项目中验证,打包后运行稳定

使用方法

开发环境

# 生成 Prisma Client
npx prisma generate

# 启动开发服务器
npm run dev

生产环境

# 构建并打包
npm run build
npm run buildw  # Windows 打包

代码中使用

// 导入 Prisma Client
import { prisma } from '@/api/config/prisma'

// 使用数据库操作
const users = await prisma.user.findMany()

验证方法

打包后检查以下内容:

  1. dist/win-unpacked/resources/app.asar.unpacked/prisma/ 存在 schema 文件
  2. dist/win-unpacked/resources/app.asar.unpacked/node_modules/@prisma/engines/ 存在查询引擎
  3. ✅ 运行打包后的应用,数据库操作正常
  4. ✅ 查看日志,确认引擎路径被正确找到

注意事项

1. Prisma 版本升级

每次升级 Prisma 版本后,需要重新生成 client:

npx prisma generate

2. 跨平台打包

如果需要为多个平台打包,确保在 schema.prisma 中配置了对应的 binaryTargets

generator client {
  provider = "prisma-client-js"
  output   = "../src/generated/client"
  binaryTargets = ["native", "windows", "darwin", "darwin-arm64", "linux-musl"]
}

3. 数据库文件位置

生产环境的数据库文件应该放在用户数据目录,而不是应用安装目录:

// ✅ 正确
const dbPath = path.join(app.getPath('userData'), 'app.db')

// ❌ 错误(安装目录通常是只读的)
const dbPath = path.join(app.getAppPath(), 'app.db')

常见问题

Q: 为什么不使用 Prisma 默认的 node_modules/.prisma/client

A: 在 pnpm 环境下,node_modules/.prisma 实际上是一个符号链接,指向 node_modules/.pnpm/@prisma+client@版本号_hash/node_modules/.prisma。这个路径在打包后会失效,导致模块找不到。通过自定义输出路径到 src 目录,可以确保代码被正确打包。

Q: 为什么需要 getQueryEnginePath 函数?

A: Prisma Client 在运行时需要加载查询引擎二进制文件。在 Electron 打包后,文件位置会发生变化,Prisma 无法自动感知新位置。必须通过 __internal.engine.binaryPath 显式指定引擎路径。getQueryEnginePath 函数实现了多路径查找逻辑,提高了容错性。

Q: 可以使用 extraResources 代替这个方案吗?

A: 理论上可以,但在 pnpm 环境下会更复杂:

  • 需要在 extraResources 中配置 pnpm 的实际路径(包含版本号和 hash)
  • 每次升级 Prisma 版本都需要更新这个路径
  • 需要额外配置 module.paths 来让 Node.js 能找到 resources 目录中的模块

我们的方案通过自定义输出路径,避免了这些复杂性。

环境信息

  • Prisma: 6.8.2
  • Electron: 34.2.0
  • electron-builder: 26.1.0
  • pnpm: 10.4.1
  • Node.js: 22.x
  • 操作系统: Windows 11

总结

这个方案的核心思路是:

  1. 避开 pnpm 的符号链接:通过自定义 Prisma Client 输出路径
  2. 简化引用:通过 package.json 软链接保持代码简洁
  3. 显式指定引擎路径:通过封装的查找逻辑和 binaryPath 配置
  4. 提高容错性:通过多路径查找机制

希望这个方案能帮助到遇到类似问题的开发者!


最后更新: 2026-01-08

posted @ 2026-01-07 16:54  脆皮鸡  阅读(3)  评论(0)    收藏  举报