你不知道的 Electron (二):瞭解 Electron 打包

騰訊IMWeb團隊發表於2018-09-20

轉自IMWeb社群,作者:laynechen,原文連結

我們知道 Electron 提供了一個類似瀏覽器,但有更多許可權的環境來執行我們的網頁,那麼 Electron 是怎麼做到將我們的網頁程式碼打包成一個可執行程式的呢?

這篇文章主要介紹如何打包 Electron 應用,以及分析 electron-builder 是如何對我們的應用進行打包的。

如何打包

Electron 目前有兩種打包工具:electron-userland/electron-builderelectron-userland/electron-packager

使用 electron-builder 打包

安裝依賴:

yarn add electron-builder --dev
// 或
npm i electron-builder --save-dev
複製程式碼

打包:

  1. 在專案的 package.json 檔案中定義 namedescriptionversionauthor 資訊。
  2. 在專案的 package.json 檔案中定義 build 欄位:
"build": {
  "appId": "your.id",
  "mac": {
    "category": "your.app.category.type"
  }
}
複製程式碼

(全部選項)

  1. 新增 scriptspackage.json
"scripts": {
  "pack": "electron-builder --dir",
  "dist": "electron-builder"
}
複製程式碼
  1. 打包

生成 package 目錄但是沒有打包為一個檔案

npm run pack
複製程式碼

生成一個 exe 或者 dmg 檔案

npm run dist
複製程式碼
  1. 指定平臺和架構
# windows 64bit
electron-builder --win --x64
# windows and mac 32bit
electron-builder --win --mac --ia32
複製程式碼

詳細引數:Command Line Interface (CLI)

使用 electron-packager 打包

安裝依賴:

npm i electron-packager --save-dev
複製程式碼

打包:

electron-packager <sourcedir> <appname> --platform=<platform> --arch=<arch> [optional flags...]
複製程式碼

最簡單的就是直接執行 electron-packager . 打包。

預設情況下, appname 為 當前專案下的 package.json 檔案中的 productName 或者 name 欄位的值;platformarch 則與主機一致,在 Windows 64位 下打包就是 Windows 64 位的版本。

具體每個欄位的值可以看 electron-packager/usage.txt

注: OS X 下打包 Windows 的應用需要安裝 Wine才行,electron-packager 需要使用 node-rcedit 編輯 Electron.exe 檔案。

Building an Electron app for the Windows target platform requires editing the Electron.exe file. Currently, Electron Packager uses node-rcedit to accomplish this. A Windows executable is bundled in that Node package and needs to be run in order for this functionality to work, so on non-Windows host platforms, Wine 1.6 or later needs to be installed. On OS X, it is installable via Homebrew.

electron-builder 打包分析

檔案大小分析

因為要達到跨平臺的目的,每個 Electron 應用都包含了整個 V8 引擎和 Chromium 核心,以致於一個空的 Electron 專案,使用 electron-builder --dir 打包後沒有壓縮的專案資料夾,大小也已經到了 121.1 MB。如果使用 electron-builder 進行打包,安裝程式的大小為 36MB,這個大小是可以接受。

但是上面是一個空的專案,那麼一個實際的專案打包之後有多大呢?一個安裝了 30+ 個依賴的專案,未生成安裝包前,專案資料夾的大小是 230+ MB,生成安裝程式後是 56.3 MB,生成安裝程式之後的大小還是可以接受的,比空專案也只大了 20MB 左右。

但其實大了 20MB 也是不太科學的,本身專案並沒有什麼大的資原始檔,如果只是程式碼的話不打包的大小應該也只有 10MB 以下。那麼是什麼讓專案的大小大了接近 100MB?

打包後的專案結構

我們看下打包後的專案結構 (electron-builder --dir)

加上 --dir 引數,不將整個應用打包成安裝檔案,來檢視一個應用的目錄結構:

.
├── locales
│   ├── am.pak
│   └── ... 一堆的 pak 檔案
├── resources
│   ├── app.asar (空專案只有 2KB,一個實際專案有 130MB+)
│   └── electron.asar (大小在 250KB 左右)
├── electron.exe (67.5MB)
└── ...
複製程式碼

這裡忽略了很多的檔案,我們主要看 electron.exe 檔案和 resources 資料夾。因此實際專案和空專案多的東西應該就是在 app.asar 上面了。

app.asar

dist/win-unpacked/resources/ 下生成了 app.asar 檔案,這是一個用 asar 壓縮後的檔案。我們可以解壓看下里面是什麼:

# 安裝 asar
npm install -g asar
# 解壓到 ./app 資料夾下
asar extarct app.asar ./app
複製程式碼

解壓目錄如下:

.
├── CHANGELOG.md
├── README.md
├── core
├── electron
├── icon
├── node_modules
├── package.json
├── test
├── view
└── webpack.config.js
複製程式碼

看到這個目錄會不會很熟悉?~實際上是把我們的整個專案的內容都打包進來了。當然對 node_modules 資料夾有特殊處理,這裡只打包了 production dependencies,即在 package.jsondependencies 中定義的依賴。

空的專案和一個實際專案的大小差距就出在依賴這裡了。

electron.asar

我們再來看下 electron.asar 打包了什麼東西:

asar extract electron.asar ./electron
複製程式碼
.
├── browser
│   ├── api
│   ├── chrome-extension.js
│   ├── desktop-capturer.js
│   ├── guest-view-manager.js
│   ├── guest-window-manager.js
│   ├── init.js
│   ├── objects-registry.js
│   └── rpc-server.js
├── common
│   ├── api
│   ├── atom-binding-setup.js
│   ├── init.js
│   ├── parse-features-string.js
│   └── reset-search-paths.js
├── renderer
│   ├── api
│   ├── chrome-api.js
│   ├── content-scripts-injector.js
│   ├── extensions
│   ├── init.js
│   ├── inspector.js
│   ├── override.js
│   ├── web-view
│   └── window-setup.js
└── worker
    └── init.js
複製程式碼

Electron 相關的原始碼被壓縮到了 electron.asar 檔案中。

打包分析

electron-builder 打包時輸出的資訊

打包的時候我們可以看到 控制檯輸出瞭如下資訊:

  • electron-builder version=20.15.1
  • loaded configuration file=package.json ("build" field)
  • writing effective config file=dist/electron-builder-effective-config.yaml
  • rebuilding native production dependencies platform=win32 arch=x64
  • packaging       platform=win32 arch=x64 electron=1.8.7 appOutDir=dist/win-unpacked
複製程式碼

如果還要打包程式的話,還有以下列印資訊:

  • building        target=nsis file=dist/xxx.exe archs=x64 oneClick=true
  • building block map blockMapFile=dist/xxx.exe.blockmap
複製程式碼

大致可以知道打包主要做了以下事情:

  1. 重新安裝依賴
  2. 打包

從這裡知道的資訊還是比較有限,所以還是得看下從輸入 electron-builder 到生成安裝程式中間經歷了什麼。

"bin"

我們從安裝的 electron-builder 依賴的 packager.json 檔案定義的 "bin" 欄位資訊可以看到它執行了 ./out/cli/cli.js 這個檔案。

"bin": {
    "electron-builder": "./out/cli/cli.js",
    "build": "./out/cli/cli.js",
    "install-app-deps": "./out/cli/install-app-deps.js"
}
複製程式碼

./out 目錄下的檔案是已經經過 babel 轉譯之後的,我們可以去下載 electron-builder 原始碼來分析。

"packages/electron-builder/src/cli/cli.ts"

從原始碼中我們不難定位到 packages/electron-builder/src/cli/cli.ts 這個檔案就是命令的入口檔案。從入口檔案往下分析:

  1. packages/electron-builder/src/builder.ts

cli.ts 檔案中 import 了上一層目錄的 builder.ts 檔案匯出的 build 方法。build 方法中建立了一個 Packager 物件,然後又呼叫了 packages/electron-builder-lib 匯出的 build 方法。

cli.ts 中的 build 方法:

export function build(rawOptions?: CliOptions): Promise<Array<string>> {
  const buildOptions = normalizeOptions(rawOptions || {})
  const packager = new Packager(buildOptions)

  let electronDownloader: any = null
  packager.electronDownloader = options => {
    if (electronDownloader == null) {
      electronDownloader = BluebirdPromise.promisify(require("electron-download-tf"))
    }
    return electronDownloader(options)
  }
  return _build(buildOptions, packager)
}
複製程式碼
  1. packages/electron-builder-lib/index.ts
export async function build(options: PackagerOptions & PublishOptions, packager: Packager = new Packager(options)): Promise<Array<string>> {
  ...

  return await executeFinally(packager.build().then(() => Array.from(artifactPaths)), errorOccurred => {
    ...
  })
}
複製程式碼

build 方法中呼叫了 packagerbuild 方法。

  1. packages/electron-builder-lib/packager.ts

build 方法對一些資訊進行處理後又呼叫了 _build 方法:

async build(): Promise<BuildResult> {
    ...
  return await this._build(configuration, this._metadata, this._devMetadata)
}
複製程式碼

_build 方法繼續呼叫了私有方法 doBuild:

async _build(configuration: Configuration, metadata: Metadata, devMetadata: Metadata | null, repositoryInfo?: SourceRepositoryInfo): Promise<BuildResult> {
    ...
    return {
      outDir,
      platformToTargets: await executeFinally(this.doBuild(outDir), async () => {
        if (this.debugLogger.enabled) {
          await this.debugLogger.save(path.join(outDir, "electron-builder-debug.yml"))
        }
        await this.tempDirManager.cleanup()
      }),
    }
}
複製程式碼

doBuild 中負責了要建立哪些平臺的安裝包、以及如何去打包:

private async doBuild(outDir: string): Promise<Map<Platform, Map<string, Target>>> {
    ...

    for (const [platform, archToType] of this.options.targets!) {
      const packager = this.createHelper(platform)

      for (const [arch, targetNames] of computeArchToTargetNamesMap(archToType, packager.platformSpecificBuildOptions, platform)) {

        await this.installAppDependencies(platform, arch)

        const targetList = createTargets(nameToTarget, targetNames.length === 0 ? packager.defaultTarget : targetNames, outDir, packager)
        await createOutDirIfNeed(targetList, createdOutDirs)
        await packager.pack(outDir, arch, targetList, taskManager)
      }
    }

    return platformToTarget
}
複製程式碼

createHelper 實際上就是根據平臺去建立相對應的 Packager 物件,另外根據不同架構去安裝應用的依賴,最後呼叫 pack 方法打包。

後面分析下打包 Windows 平臺的 WinPackager

WinPackager

實際上 WinPackager 是繼承於 PlatformPackager 類,pack 方法也是在這個父類裡面定義的:

async pack(outDir: string, arch: Arch, targets: Array<Target>, taskManager: AsyncTaskManager): Promise<any> {
    const appOutDir = this.computeAppOutDir(outDir, arch)
    await this.doPack(outDir, appOutDir, this.platform.nodeName, arch, this.platformSpecificBuildOptions, targets)
    this.packageInDistributableFormat(appOutDir, arch, targets, taskManager)
}
複製程式碼

這個方法裡面又是呼叫了另一個方法 doPack

protected async doPack(outDir: string, appOutDir: string, platformName: string, arch: Arch, platformSpecificBuildOptions: DC, targets: Array<Target>) {
    ...

    const computeParsedPatterns = (patterns: Array<FileMatcher> | null) => {
      if (patterns != null) {
        for (const pattern of patterns) {
          pattern.computeParsedPatterns(excludePatterns, this.info.projectDir)
        }
      }
    }

    const getFileMatchersOptions: GetFileMatchersOptions = {
      macroExpander,
      customBuildOptions: platformSpecificBuildOptions,
      outDir,
    }
    const extraResourceMatchers = this.getExtraFileMatchers(true, appOutDir, getFileMatchersOptions)
    computeParsedPatterns(extraResourceMatchers)
    const extraFileMatchers = this.getExtraFileMatchers(false, appOutDir, getFileMatchersOptions)
    computeParsedPatterns(extraFileMatchers)

    const packContext: AfterPackContext = {
      appOutDir, outDir, arch, targets,
      packager: this,
      electronPlatformName: platformName,
    }

    const taskManager = new AsyncTaskManager(this.info.cancellationToken)
    const asarOptions = await this.computeAsarOptions(platformSpecificBuildOptions)
    const resourcesPath = this.platform === Platform.MAC ? path.join(appOutDir, framework.distMacOsAppName, "Contents", "Resources") : (isElectronBased(framework) ? path.join(appOutDir, "resources") : appOutDir)
    this.copyAppFiles(taskManager, asarOptions, resourcesPath, path.join(resourcesPath, "app"), outDir, platformSpecificBuildOptions, excludePatterns, macroExpander)
    await taskManager.awaitTasks()

    const beforeCopyExtraFiles = this.info.framework.beforeCopyExtraFiles
    if (beforeCopyExtraFiles != null) {
      await beforeCopyExtraFiles(this, appOutDir, asarOptions == null ? null : await computeData(resourcesPath, asarOptions.externalAllowed ? {externalAllowed: true} : null))
    }
    await BluebirdPromise.each([extraResourceMatchers, extraFileMatchers], it => copyFiles(it))

    await this.info.afterPack(packContext)
    await this.sanityCheckPackage(appOutDir, asarOptions != null)
    await this.signApp(packContext)
    await this.info.afterSign(packContext)
}
複製程式碼

這裡我們知道了,app.asar 檔案就是在這個方法中生成的。

在打包的時候,是通過 Matcher 來實現選擇性的打包哪些檔案。從 FileMatcher 中可以看到相關定義:

export const excludedNames = ".git,.hg,.svn,CVS,RCS,SCCS," +
  "__pycache__,.DS_Store,thumbs.db,.gitignore,.gitkeep,.gitattributes,.npmignore," +
  ".idea,.vs,.flowconfig,.jshintrc,.eslintrc,.circleci," +
  ".yarn-integrity,.yarn-metadata.json,yarn-error.log,yarn.lock,package-lock.json,npm-debug.log," +
  "appveyor.yml,.travis.yml,circle.yml,.nyc_output"

export const excludedExts = "iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts"
複製程式碼

electron.exe

我們執行的 electron.exe 可執行程式,實際上是早就已經編譯好的檔案。他的功能就是載入 resources/app.asar 檔案中的內容,包括入口檔案的位置,也是從 app.asar 中打包的 package.jsonmain 欄位來獲取載入。

打包工具需要做的事情只是把這個 electron.exe 檔案修改下圖示、作者、版本等資訊即可。

總結

上面簡單的對 electron-builder 的打包過程進行了分析。通過分析,我們瞭解了:

  1. Electron 應用體積的分佈情況:

electron.exe 在 67.5MB 左右,electron.asar 在 250KB 左右,app.asar 則根據實際專案差別會比較大,空的專案在 2KB 左右,測試中的一個實際專案在 130MB 左右。app.asar 大的原因在於實際專案依賴上會比較多,而打包工具在打包時是需要將整個 node_modules 資料夾都打包進來的,因此體積上會大很多。

  1. 可執行檔案是怎麼來的

通過實現一個通用的可執行程式,這個程式做的事情是將 resources/app.asar 作為專案根目錄,執行 app.asar/package.jsonmain 指定檔案作為入口檔案。不同的應用程式只需要重新打包好相應的 app.asar 即可。最後對這個可執行程式的圖示等資訊進行修改就可以得到我們的應用程式了~

  1. 打包可能存在的問題

electron-builder 打包雖然幫我們把一些檔案過濾掉不進行打包,但是我們的專案原始碼是沒有經過任何處理的被打包了進去。

【參考資料】

PS:關於Electron打包優化,可以參考作者的另一篇文章《Electron 打包優化 - 從 393MB 到 161MB》

相關文章