第五章 Electron 底层原理

本文所有源码均在:github.com/Sunny-117/e...

本文收录在《Electron桌面客户端应用程序开发入门到原理》掘金专栏

本文介绍

这一章主要是聚焦于 Electron 底层一些比较重要的特性,针对一些非常重要的代码片段进行剖析。

  • Electron 源码目录的结构
  • Electron 如何做到跨平台
  • Electron 本身 API 是如何为开发者提供支持的
  • 进程间是如何通信
  • ...

还会包含一些和 Electorn 相关的周边工具的原理剖析

  • electron-builder
  • electron-updater

以及还会介绍一些看似和 Electron 工程没有关系,但是其实是比较重要的一些原理。

  • V8引擎执行的原理
  • 垃圾回收相关原理

源码结构

我们可以在 github 上面找到 Electron 的源码地址:github.com/electron/el...

打开 Electorn 源码地址后,我们会看到这是一个非常庞大的项目,接下来我们针对该项目的主要目录进行一个介绍。

  • build:该目录主要是存放构建 Electron 工程相关的脚本
  • buildflags:此目录放置一些编译相关的配置文件,这些配置文件主要用于在构建 Electron 工程的时候裁剪掉不必要的模块:
    • pdf_viewer
    • color_chooser
    • spellchecker
    • ....
  • chromium_src:该目录放置一些从 chrome 项目中复制过来的代码。
  • default_app:这是 Electron 为我们提供的一个默认应用,当我们安装 Electron 的时候,会有一个 default_app,该目录下面会有一个 default_app.asar。
  • docs:该目录是用于存放文档的目录,官方的 API 以及示例文档都存放于此目录。
  • lib:此目录下面存放了一堆 TS 文件,这些 TS 最终会被编译为 JS,成为供开发者使用的 API,诸如 app、ipcMain、ipcRenderer 这些 Electron 所提供的 API,上层部分就是对应的这里的代码。这些 API 的下层实际上对应的还是原生的 C++ 代码,这些代码放置于 shell 目录下面。
  • npm:该目录下存放 Electron 作为一个 npm 模块相关的代码,当开发者安装 Electron 模块的时候,node_modules/electorn 里面所对应的内容,就是由该目录下面的内容生成的。
  • patches:该目录下面存放了一系列的补丁。虽然我们说 Electron 集成 chromium、node.js,但是并不是说集成的这两个大项目原封不动的什么都没改,Electron 在集成这两者的时候,针对 chromium 和 node.js 源码本身是做了很多修改的,这里要修改这两个项目的源码,就是通过打补丁的方式。
  • script:存放的是编译所需的工具脚本。
  • shell:该目录对应的就是一系列的 C++ 文件,该目录和上面的 lib 是对应着的,相当于是给开发者提供的 Electron 自身 API 的下层实现。
  • spec-chromium:此目录下面存放的是 chromium 相关的测试代码。
  • spec:此目录下面存放的也是一些测试代码,这里的测试是测试 Electron 自身 API 的,例如 clipboard、dialog 等 API。
  • typings:此目录用于存放 TypeScript 相关的类型文件。

不同进程下Nodejs环境

主进程初始化Node.js环境

wWinMain 入口函数位于 shell\app\electron_main.cc 文件里面,该函数是 Windows 操作系统下面的入口函数。

该初始化函数会做诸如下面的事情:

  • 命令行指令处理
  • 环境变量的初始化

ContentMain 方法主要是用于启动 Chromium。

该方法接受一个代理对象作为参数,该代理对象 ElectronMainDelegate 对应的代码位于 shell\app\electron

_main_delegate.cc 文件里面。

工程师可以对这个代理对象进行一些初始化的工作,从而达到对 Chromium 进行一些二次开发的目录。

Electron 工程师也是利用了该代理对象,注入了自己的逻辑。

  • ElectronBrowserClient 的对象:shell\browser\electron_browser_client.cc 文件,该对象由继承与 Chromium 的 ContentBrowserClient 这个类,该类提供了一系列的方法:

    • IsBrowserStartupComplete:判断浏览器核心是否完全启动
    • IsShuttingDown:判断浏览器核心是否被关闭
    • RenderProcess-WillLaunch:是否有新的渲染进程将要被加载

    因为 ElectronBrowserClient 是继承于 ContentBrowserClient 类,所以也有上面所提到的这些方法。

  • 接下来通过 ElectronBrowserClient 类的 CreateBrowserMainParts 方法,会去创建一个名为 ElectronBrowserMainParts 的对象,对应的源码文件在 shell\browser\electron_browser_main_parts.cc 文件里面。

    • 该对象继承于 Chromium 的 BrowserMainParts 这个类,BrowserMainParts 这个类会包含 Chromium 启动过程中的一系列事件
    • 整个 Electron 就在在这个对象所对应的 PostEarly Initialization 这个事件中初始化的 Node.js 环境。
    • PostEarlyInitialization 事件实际上是 Chromium 启动的时候的一个比较早期的事件,类似的事件还有:
      • PreCreateMain-MessageLoop:浏览器主进程消息循环开始前事件
      • PostCreateMainMessageLoop:浏览器主进程消息循环开始后事件
      • OnFirstIdle:主进程第一次进入空闲时的事件
    • PostEarlyInitialization 部分源码:
      • electron_bindings_ 其实是 ElectronBindings 类的一个实例对象,对应的 BindTo 方法是为 process 对象提供一系列的扩展方法和属性:
        • getCPUUsage
        • crash
        • getCreationTime
      • node_bindings_ 是 NodeBindings 类的一个实例:
        • Initialize 方法:初始化了 Electron 的操作系统所支持的逻辑,比如剪切板、系统菜单、托盘图标之类的控制逻辑
        • LoadEnvironment 方法:最终初始化 Node.js 运行环境的方法

渲染进程初始化 Node.js 环境

首先和之前在主进程中初始化 Node.js 环境一样,会传入一个名为 ElectronMainDelegate 的代理对象。

接下来每创建一个渲染进程,都会执行代理对象的 CreateContentRenderer-Client 方法。该方法又会创建一个名为 ElectronRendererClient 对象,该对象的源码位于 shell\renderer\electron_renderer_client.cc 文件里面,该对象继承于 Content-RendererClient 这个类,这个类为整个渲染进程的生命周期暴露了一系列的事件:

  • DidCreateScriptContext :该事件会在渲染进程的 JS 执行环境准备就绪的时候触发
  • WillRelease-ScriptContext:该事件会在卸载渲染进程的 JS 执行环境的时候触发

渲染进程中初始化 Node.js 环境的过程就是在 DidCreateScriptContext 这个事件中执行的。

API支持

公开API

在 Electron 中,有些自己的 API:

  • 访问剪贴板
  • 创建系统菜单
  • 创建托盘图标
  • ...

这些 API 是如何公开的 ?

在 Electron 源码的 lib 目录下面,存储了一系列的 TS 文件,这些 TS 文件就为开发者提供了 Electron 自身的 API,比如有:

  • app
  • ipcMain
  • ipcRenderer
  • ...

这些 TS 所提供的 API 最终是会被注入到 Node.js 里面的。

首先这些 TS 文件是需要被编译为 JS 文件才能够被 Node.js 所执行。在 Electron 里面,使用的是 Webpack 来对这些 TS 文件进行编译。

编译 TS 文件的工作是被定义在 Electron 的编译脚本 BUILD.gn 的文件里面。

经过这个编译脚本对 TS 文件进行编译之后,就会生成一系列的 JS 文件:

  • asar_bundle.js
  • browser_init.js
  • isolated_bundle.js
  • renderer_init.js
  • sandbox_bundle.js
  • worker_init.js

接着,编译指令会通过一个名为 js2c.py 的 python 文件将这些 JS 转换为 ASCII 码形式的 C 数组,最终会生成一个名为 electron_native.cc 的文件。

在 electron_native.cc 文件里面,有一个名为 LoadEmbedderJavaScriptSource 的方法,该方法的作用是用于读取 ASCII 码数组的内容,并执行这些内容,这里执行这些内容其实就是在执行相应的 JS 逻辑,也就是执行了一开始的 TS 逻辑。

Electron 编译工具在编译 Node.js 源码之前,会以补丁的形式将 electron_native.cc 这个文件注入到 Node.js 源码里面。

在 Electron 中有另外一个补丁,该补丁位于源码的 patches\node\build_modify_js2c_py_to_allow_injection_of_original-fs_and_custom_embedder_js.patch 这个位置,该源码包含这样一段代码:

js 复制代码
 LoadJavaScriptSource();
+  LoadEmbedderJavaScriptSource();

在上面的代码中,LoadEmbedderJavaScriptSource 方法前面有一个加号,代表该方法会以补丁的形式添加进去,会为 NativeModuleLoader 类型的构造函数增加这么一个函数调用。

NativeModuleLoader 会在主进程初始化 Node.js 环境的时候被实例化,因此也就代表着在主进程初始化 Node.js 环境的时候,TS 逻辑就已经被执行了,自身的那些 API 已经就存在了。

不同进程的不同 API

在 Electron 中,主进程和渲染进程能够使用的 API 是不同的。

  • 主进程:可以使用 app、ipcMain 等模块
  • 渲染进程:可以使用 ipcRenderer、webFrame 等模块
  • clipboard、desktopCapturer 之类的模块主进程和渲染进程都可以使用

前面我们提到 Electron 里面通过 Webpack 来对 TS 进行编译,编译之后会生成一系列的 JS 文件,其中就有:

  • browser_init.js
  • renderer_init.js

而这两个文件就是为不同的进程提供服务的,生成这两个文件所使用的输入信息也是不同的

  • 生成 browser_init.js ,所提供的输入信息是 auto_filenames.browser_bundle_deps 编译变量
  • 生成 renderer_init.js,所提供的输入信息是auto_filenames.renderer_bundle_deps 编译变量

例如,我们来看一下 auto_filenames.renderer_bundle_deps 编译变量所对应的数组,里面包含了一席勒的 TS 文件,但是在这个数组里面,并不包含:

  • lib/browser/api/app.ts
  • lib/browser/ipc-main-impl.ts

这两个 TS 文件。

但是如果是为主进程服务的 auto_filenames.browser_bundle_deps 编译变量所对应的数组,就会包含上面所罗列的这两个 TS 文件。

另外,这两个编译变量所对应的数组,都会包含:

  • lib/common/api/clipboard.ts
  • lib/renderer/api/desktop-capturer.ts

系统底层支持

应用入口文件的加载

每个 Electron 的应用都是从主进程的入口脚本开始执行的。

之前我们提到了一个 LoadEmbedderJavaScriptSource 方法,当 Electron 执行这个方法的时候,实际上就会执行一系列 TS 相关的代码,其中就有一个 TS 文件 lib\browser\init.ts,该文件其实就完成了加载应用入口脚本的工作。

在该文件中,有一个 searchPaths 数组,该数组值的顺序其实就是 Electron 中查找对应的入口文件的顺序。

整个查找顺序依次为:

  • app子目录
  • app.asar
  • default_app.asar
  • 如果还是没有找到,那么就会报异常并退出

如果命中了某一个文件,那么就会去读取对应的 package.json 文件,读取之后就会根据 package.json 文件里面的信息执行一系列的配置逻辑:

  • 设置应用默认版本
  • 设置应用默认的 DesktopName

配置工作完成之后,Electron 就会把 pacakge.json 里面所对应的 main 的脚本交给 Node.js 来执行。

mainStartupScript 对应的是主进程的入口脚本,如果package.json 里面没有配置 main 属性,那么默认为 index.js

另外,还有一个 Module 模块,这是Node.js所提供的一个内置模块,这里用到:

  • _resolveFilename
  • _load:就是负责加载并且执行开发者所提供的主进程所对应的入口脚本文件。

提供系统底层支持

有关 Electron 自身所提供的 API 能力,在前面我们介绍过,是由 lib 目录下面一系列的 TS 文件提供的。

但是仅仅依靠 TS 是做不了这些和操作系统相关的工作的,这些工作最终的底层实现还是由 C++ 代码来实现的。

之前,我们在介绍 Electron 中初始化 Node.js 环境期间,会执行 NodeBindings 对象的 Initialize 方法,该方法内部,又会调用一个名为 RegisterBuiltinModules 的方法(shell\common\node_bindings.cc),该方法会注册 Electron 为 Node.js 提供的扩展库。

ELECTRON_BUILTIN_MODULES 这个宏对应的代码,实际上就是在注册 Electron 扩展模块的底层支持模块。

以 app 模块为例,里面也对应了一些宏,通过这些宏,最终能够定位到 Initialize 方法,在该方法中有一个字典,字典对应的键就是 app,对应的值是一个异步对象,之后上层代码在首次使用这个 app 对象的时候,就会执行这个异步对象的初始化方法,之后使用这个对象都会从缓存中去获取。

API支持不同的操作系统

这里以 AddRecentDocument 方法为例,不同的操作系统,实现写在了不同的文件里面:

  • Windows:browser_win.cc
  • Mac:browser_mac.mm
  • Linux:browser_linux.cc 但是对应的方法是一个空方法

这里我们就可以看出,同一个 API,为了支持不同的操作系统,Electron 中将实现写在了不同的文件里面。

接下来在 filenames.gni 文件中定义了编译文件的路径数组,在这个数组里面,前面三项定义了不同的操作系统下执行编译工作是需要编译的文件,而数组的最后一项表示的是三个系统都需要编译的文件。

之后,会在 BUILD.gn 文件里面使用这三个数组。

解析 asar 文件

asar 文件本身是一个归档文件,该文件会把项目中的每一个文件的文件名、路径信息、开始位置、长度信息等都记录在一个名为 header 的结构体里面,header 本身的大小也会被包含在 asar 文件中。

另外,Electron 自身也内置了 asar 文件的解析能力,并且还重写了 Node.js 的 require 方法。

当 Electron 执行开发者的代码,遇到 require 方法要加载本地文件内容的时候,asar 文件所重写的 require 方法就会介入,从 header 里面检索出文件的位置和大小,再根据这些信息读取文件的内容。

这也就意味着 asar 内部在加载文件的时候,并非将整个 asar 文件的内容都加载到内存里面,而是只加载指定长度的数据。

在初始化 Electron 系统底层模块的时候,会执行一个名为 InitAsarSupport 的方法,源码位于 shell\common\api\electron_api_asar.cc

在 InitAsarSupport 的方法里面,接收一个 require 作为参数,这个 require 就是原本的 Node.js 的 require 方法,在该方法内部,就对原本的 require 方法进行修改。

在 Electron,仍然是通过打补丁的方式来修改 Node.js 的源码,从调用上面的 InitAsarSupport 方法。

回到 InitAsarSupport 方法,内部调用了一个名为 CompileAndCall 的方法,在该方法中,就执行了 asar 模块的初始化脚本。

前面我们有讲过,Electron 会将一系列的 TS 文件编译为 JS 文件,其中就有一个名为 asar_bundle.js 的文件,而该文件就会在这个 CompileAndCall 方法里面被执行。

另外,在 init.ts 和 fs-wrapper.ts 文件里面,还局部的修改了 fs 模块的一些内部,修改了:

  • open
  • openSync
  • copyFile
  • ...

这就意味着用户使用 require 方法加载 asar 文件内部的模块,以及使用 fs 模块读取 asar 文件内部的模块的时候,执行的其实都是 Electron 提供的内部实现,而非 Node.js 原本的实现。

另外,child_process 以及 process 模块里面,也会涉及到部分读取相关的 API:

  • child_process:execFile 方法
  • process:dlopen 方法

针对这一系列方法,在 Electron 内部也是修改了的。

Electorn 无法执行一个位于 asar 文件内部的可执行程序,如果你的 asar 文件内部存在可执行程序,那么 Electron 会先把这一类可执行程序释放到一个临时目录下面,再执行 execFile 方法,因此这样操作是会增加一些开销的,所以不建议把可执行程序打包到 asar 文件里面。

asar 文件中关于文件信息的获取,并非是在 init.ts 和 fs-wrapper.ts 文件里面,而是在 shell\common\api\electron_api_asar.cc 文件里面的 Stat 方法中来获取文件信息。

进程间通信

在 Chromium 内部,进程之间的通信是使用的一个名为 Mojo 的框架,对应的官方介绍文档:chromium.googlesource.com/chromium/sr...

Mojo 主要是提供了一套底层的 IPC 实现:

  • 消息管道
  • 数据管道
  • 共享缓冲区
  • ...

Chromium 在 Mojo 的基础上,由做了一层封装:

  • 简化不同语言(C++、Java、JS)之间的消息传递
  • 简化不同进程之间的消息传递

说回 Electron,因为 Electron 内部集成了 Chromium,因此 Electorn 中进程间的通信也是通过 Mojo 来实现的。

在 api.mojom 文件中(源码地址:shell\common\api\api.mojom)定义了一系列的通信接口描述,其中的 ElectronRenderer 和 ElectronBrowser 这两个接口就和主进程以及渲染进程的通信有关了。

之后,Electorn 会在 BUILD.gn 文件中把 api.mojom 这个文件添加到编译配置文件中,接下来在编译 Electron 源码的时候,Mojo 框架会把这两个通信接口:

  • 转译为具体的实现代码
  • 将其写入到 shell/common/api/api.mojom.h 头文件里面

之后有两个文件

  • shell\renderer\api\electron_api_ipc_renderer.cc
  • shell\browser\api\electron_api_web_contents.cc

就都会引用上面的头文件,也就是说,渲染进程的底层逻辑和主进程的底层逻辑都引用了上面的头文件。

当 Electron 开发者使用 invoke 来向主进程进行通信的时候,实际上执行的是位于 shell\renderer\api\electron_api_ipc_renderer.cc 里面的 C++ 代码,具体来讲是内部的一个名为 Invoke 的方法。

在 electron_api_ipc_renderer.cc 文件内部的 Invoke 方法中,首先创建了一个 Promise,之后调用了 electron_browser_remote_ 这个对象的 Invoke 方法,而 electron_browser_remote_ 这个对象正是 Mojo 的一个通信对象。

当调用了 Mojo 通信对象的 Invoke 方法之后,Mojo 会组织要传递给主进程的数据和消息,然后发送给主进程。

主进程接收到消息,最终会执行 electron_api_web_contents.cc 文件里面的 WebContents::Invoke 方法。

在 WebContents::Invoke 方法内部,会发射一个名为 -ipc-invoke 的事件,并且将渲染进程传递过来的数据也一并发射过去,这个事件会触发位于 lib\browser\api\web-contents.ts 相关的处理逻辑。

在 web-contents.ts 这个 TS 文件中,就监听了 -ipc-invoke 事件,在对应的具体的处理逻辑里面,会去查找一个 Map 对象,之所以要用到这个 Map 对象,是因为需要看用户是否在这个 Map 对象里面注册了自己的处理逻辑:

  • 如果有,那么就执行用户提供的业务代码
  • 如果没有,抛出异常

另外,因为是 Invoke 方法,所以还需要将处理结果返回给渲染进程,这个过程是由 event.sendReply 来实现的,具体对应的源码位置在 shell\browser\api\event.cc

开发者可以通过 ipcMain.handle 来为主进程注册某一个事件的处理逻辑,实际上底层对应的代码所做的事情,仅仅是把用户的处理逻辑包装起来存放到 Map 对象里面,到时候事件发生的时候就可以进行调用。

页面事件

Electron 中提供了一系列的页面事件:

  • did-finish-load:在页面加载完成后触发
  • did-create-window:在页面通过 window.open 创建一个新窗口的时候触发
  • context-menu:用户在页面中右键唤起右键菜单的时候触发

这里我们以 did-finish-load 事件为例,来看一下 Electron 的 webContents 是如何发射这些事件的。

webContents 是使用 C++ 实现的一个类,对应的源码位置 shell\browser\api\electron_api_web_contents.cc

webContents 这个类是继承于 Chromium 里面的 content::WebContentsObserver 这个类,这个类表示一个具体的页面。

当页面的实例运行到一定的环节的时候(比如页面加载完成)就会执行该实例的一个名为 DidFinishLoad 的虚方法。

Electron 中重写了 WebContents 的这个方法,在重写逻辑中,会判断当前页面是否为 iframe 子页面,如果不是子页面,那么就会调用 Emit 方法。

注意这个 Emit 方法也不是 Node.js 里面原生的 Emit 方法,同样是 Electron 重写过的,是 Electorn 自己所实现的一个模板方法。

Emit 内部又会调用 EmitWithEvent 的模板方法,这两个模板方法都是在为事件的执行准备 JS 的执行环境。

紧接着会调用 EmitEvent 的模板方法,源码位置:shell\common\gin_helper\event_emitter_caller.h

EmitEvent 方法内部,将 did-finish-load 事件名和事件回调方法所需要的参数存储到了一个对象里面,然后调用 CallMethodWithArgs 方法(CallMethodWithArgs 源码位置:shell\common\gin_helper\event_emitter_caller.cc)

CallMethodWithArgs 方法内部,最终调用了 Node.js 内置函数 node::akeCallback 方法,而这个内置函数的作用就是在 webContents 对象实例上面执行 Node.js 的 emit 方法。

electron-builder工作原理

  • 使用 electron-builder 前的准备工作
  • electron-builder 的工作流程

使用 electron-builder 前的准备工作

1. 构建前端项目

如果我们的 Electron 项目使用到了现代前端技术进行开发的,那么首先第一步需要对整个项目进行构建,生成普通的 HTML、CSS 以及 JS,并且放置到一个指定的输出目录内,回头 Electron 在启动的时候,运行的是输出目录里面的文件。注意,这一步 electron-builder 是不会帮我们做的

2. 准备package.json

我们还需要在输出目录创建一个 package.json,主要用于指定主进程的入口文件是哪一个。注意这个 package.json 由于是为 Electron 服务的,起到的作用主要是用户启动应用时,Electron 知道首先加载哪一个脚本,因此该文件只在生产环境中起效。

electron-builder 的工作流程

1. 收集配置信息

当我们使用 electron-builder 进行打包的时候,electron-builder 做的第一件事情就是收集配置信息:

  • 应用图标
  • 应用名称
  • 应用 id
  • 附加资源
  • ...

注意这些配置信息中,有一些是必须要提供,如果没有提供打包会报错,另外一些是可选的,没有提供的话,会使用默认值。

当收集工作完成之后,会生成一个全量的配置信息用于接下来的打包工作。

2. 安装依赖

接下来 electron-builder 会检查输出目录下面的 package.json 是否存在依赖,如果存在,那么 electron-builder 会帮助我们在输出目录下面安装这些依赖。

这里需要注意一个点,如果我们的项目是使用 Vue、React 等现代框架开发的,依赖了很多第三方模块,那么这一部分模块在前面进行构建的时候已经被打包到业务脚本里面了,因此输出目录下面的 package.json 不应该 书写这些依赖模块。

包括主进程的业务代码也同理,应该是在被构建工具处理之后再写入安装包,因为构建工具会帮你 编译、压缩 所依赖的第三方模块。如果我们没有经过构建工具的处理就直接使用 electron-builder 进行打包,electron-builder 倒是会帮你安装第三方模块,但是不会帮你做编译、压缩等工作。

3. 生成 asar 文件

electron-builder 会根据用户配置信息来判断是否需要把输出目录下的文件进行一个合并,合并成一个 asar 文件。

如果开发者没有配置该信息,那么默认是会生成 asar 文件的。

4. 准备相关资源

electron-builder 接下来会从配置信息中获取到安装包生成目录的路径,然后将存储在缓存目录下面的 Electron可执行程序 以及 其他依赖的资源 拷贝到安装包下的目录。

例如 Widnows 操作系统,会将 Electron可执行程序 以及 其他依赖的资源 拷贝到安装包所申城的目录下的 win-unpacked 子目录下面。

如果 electorn-builder 没有在缓存目录下找到 Electorn 可执行程序以及依赖的资源,则会去服务器上面去下载,然后重新在缓存目录缓存好,在进行拷贝操作。

electron-builder 除了准备 Electron 相关的文件以外,还会将上一步所生成的 asar 文件也拷贝到 win-unpacked/resources 目录下面

5. 准备一些附加资源

除了上述的和 Electron 相关的文件以及 asar 文件以外,electron-builder 还会检查用户是在在配置信息中指定了 extraResources 配置项,如果有这一项配置,说明有额外的附加资源,此时就会根据配置将这些附加资源拷贝到对应的目录里面。

6. 修改可执行程序

接下来,所有文件都已经准备好了,electorn-builder 会修改 electron.exe 的文件名以及文件信息,按照开发者在配置文件中所提供的信息来进行修改,除此之外,还有应用程序的图标、版本号、版权信息之类的更新,也是在这一步。

例如 VSCode:

7. 应用签名

如果开发者在配置信息中指定了签名信息,那么接下来 electron-builder 会进行应用的签名。

在 Windows 操作系统下,electron-builder 会使用一个名为 winCodeSign 的工具来为可执行文件签名。

之所以要签名,是因为只有签名之后的应用才能分发到对应系统的应用商店里面。

上述工作做完之后,子目录 win-unpacked 里面存放的就是我们应用的绿色版或者叫解压版,此时开发者完全可以在该目录启动应用程序,来检查应用程序是否能够正常运行。

8. 压缩资源

electorn-builder 会使用一个叫做 7z 的压缩工具,将 win-unpacked 目录下面的内容压缩为一个 yourProductName-version.nsis.7z 的压缩包。

9. 生成卸载程序

electron-builder 会使用一个名为 NSIS 的工具来生成卸载程序的可执行文件。

NSIS 工具,英语全称为 Nullsoft Scriptable Install System,是一个开源的系统,专门用于创建 Windows 系统下的安装程序的。

这个卸载程序内部记录了 win-unpacked 目录下所有文件的相对路径,当用户去卸载应用的时候,卸载程序其实就是根据这些相对路径去挨着挨着删除这些文件,注意在进行删除的时候,还会去删除安装时使用到的一些注册表相关信息。

10. 生成安装程序

同样是使用上一步所提到的 NSIS 这个工具来生成安装包。

安装包的原理其实就是将上面的压缩包资源以及上一步的卸载程序写入到安装程序的可执行文件里面。当用户执行该安装程序的时候,这个可执行文件会读取自身的资源,将这些资源释放到用户指定的安装目录。

另外,如果开发者配置了签名的逻辑,那么 electron-builder 也会对安装程序的可执行文件进行一个签名操作,至此,一个应用程序的安装包就制作完成了。

electron-updater工作原理

  • 如何校验新版本的安装包
  • Mac应用的升级原理

如何校验新版本的安装包

当我们对一个 Electron 应用进行打包之后,生成的打包文件中会有一个安装包的描述文件。

例如如果是在 Mac 平台,对应的文件就是 latest-mac.yml

js 复制代码
version: 1.0.1
files:
  - url: Markdown Editor-1.0.1-arm64-mac.zip
    sha512: bi8e89dphwa7tL8tc9IzmM5Rd8lnqZ7f0LzP0YIiSeXcoMQyV0LH/1mUCmGDJ6U3hxOAi7LQtRRltWUJYvpw/w==
    size: 94319192
  - url: Markdown Editor-1.0.1-arm64.dmg
    sha512: sjLBnvkwfkkj8SM5DK3/Epc/i85POjbduH6ezSVquzO9v+ZTtY9Xv30FJnuPeU8xUfGQ/4rA5iYRRBHTBA4T5Q==
    size: 99102705
path: Markdown Editor-1.0.1-arm64-mac.zip
sha512: bi8e89dphwa7tL8tc9IzmM5Rd8lnqZ7f0LzP0YIiSeXcoMQyV0LH/1mUCmGDJ6U3hxOAi7LQtRRltWUJYvpw/w==
releaseDate: '2024-02-29T01:42:28.560Z'

这是一个新的安装包描述文件:

  • 新版本安装文件的版本号
  • 新版本安装文件的文件名
  • 新版本安装文件的 sha512 值
  • 新版本安装文件的文件大小
  • 新版本安装文件的生成日期

当应用执行 autoUpdater**.**checkForUpdatesAndNotify 这个方法的时候,最先从服务器请求的就是这个 latest-mac.yml 文件。

得到这个文件的内容之后,会将该文件的版本号和本地桌面应用的版本进行一个对比,如果文件的版本号比本地应用的版本号更新,说明有更新,那么就会进入到后续逻辑,否则就退出更新的逻辑。

  • Windows 系统:默认情况下新版本的安装包会被下载到 C:\Users[yourUser-Name]\AppData\Local[yourAppName]-updater目录下
  • Mac系统:默认情况下新版本的安装包会被下载到 /Users/[yourUserName]/AppData/Local/[yourAppName]-updater目录下

下载这一步完成之后,接下来 electron-updater 还会去校验所下载的文件是否合法。

校验的工作主要分为两步:

  1. 验证下载的文件的 sha512 值是否合法

具体的校验流程:在 lastest.yml 文件中有一个 sha512 值,然后 electron-updater 会去计算所下载的新的安装包的 sha512 值,然后将两个 sha512 值进行对比,如果值相等,则验证通过,不相等则验证失败。

js 复制代码
import { createHash } from "crypto"
import { createReadStream } from "fs"
function hashFile(file: string, algorithm = "sha512", encoding: "base64" | "hex" = "base64", options?: any): Promise<string> {
  return new Promise<string>((resolve, reject) => {
    const hash = createHash(algorithm)
    hash.on("error", reject).setEncoding(encoding)
    createReadStream(file, {...options, highWaterMark: 1024 * 1024})
      .on("error", reject)
      .on("end", () => {
        hash.end()
        resolve(hash.read() as string)
      })
      .pipe(hash, {end: false})
  })
}

在上述代码中,引入了 Node.js 的 crypto 内置模块的 createHash 方法来创建一个哈希对象。接着使用 fs 内置模块的 createReadStream 方法读取安装包的文件,将读取流转移到哈希对象里面。当读取流读取工作完成之后,文件的 sha512 值也就计算出来了。

  1. 接着会去验证新的安装文件的签名是否合法
js 复制代码
export function verifySignature(publisherNames: Array<string>, unescapedTemp  UpdateFile: string, logger: Logger): Promise<string | null> {
  return new Promise<string | null>(resolve => {
    const tempUpdateFile = unescapedTempUpdateFile.replace(/'/g, "''").replace(/'/g, "''");
    execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-InputFormat", "None", "-Command", 'Get-AuthenticodeSignature '${tempUpdateFile}' | ConvertTo-Json -Compress | ForEach-Object { [Convert]::ToBase64String ([System.Text.Encoding]::UTF8.GetBytes($_)) }'], {
      timeout: 20 * 1000
    }, (error, stdout, stderr) => {
      try {
        if (error != null || stderr) {
          handleError(logger, error, stderr)
          resolve(null)
          return
        }
        const data = parseOut(Buffer.from(stdout, "base64").toString("utf-8"))
        if (data.Status === 0) {
          const name = parseDn(data.SignerCertificate.Subject).get("CN")!
          if (publisherNames.includes(name)) {
            resolve(null)
            return
          }
        }
        const result = 'publisherNames: ${publisherNames.join(" | ")}, raw info: ' + JSON.stringify(data, (name, value) => name === "RawData" ? undefined : value, 2)
        logger.warn('Sign verification failed, installer signed with incorrect certificate: ${result}')
        resolve(result)
      }
      catch (e) {
        logger.warn('Cannot execute Get-AuthenticodeSignature: ${error}. Ignoring signature validation due to unknown error.')
        resolve(null)
        return
      }
    })
  })
}

Mac应用升级原理

默认情况下,当我们使用 electron-builder 进行打包的时候,会使用一个 Squirrel.Mac(github.com/Squirrel/Sq... Mac 下面的安装包,因此 electron-updater 在 Mac 环境下的升级逻辑也是基于这个工具来实现的。

刚才在前面已经校验了安装包,确认没有问题,接下来就需要进入到下一步,进行升级的一个操作。但是这里并���能直接将安装包交给 Squirrel.Mac 工具进行升级,而是采用在本地启动一个 localhost 的 http 服务,以 Squirrel.Mac 要求的方式提供响应。

启动 http 服务器代码如下:

js 复制代码
import { createServer, IncomingMessage, ServerResponse } from "http"
private nativeUpdater: AutoUpdater = require("electron").autoUpdater
const server = createServer()
function getServerUrl(): string {
  const address = server.address() as AddressInfo
  return 'http:// 127.0.0.1:${address.port}'
}
server.listen(0, "127.0.0.1", () => {
  this.nativeUpdater.setFeedURL({
    url: getServerUrl(),
    headers: {"Cache-Control": "no-cache"},
  })
  this.nativeUpdater.once("error", reject)
  // The update has been dowloaded and is ready to be served to Squirrel
  this.dispatchUpdateDownloaded(event)
  if (this.autoInstallOnAppQuit) {
    // This will trigger fetching and installing the file on Squirrel side
    this.nativeUpdater.checkForUpdates()
  }
})

当这个本地服务器启动成功之后,应用那边在执行 autoUpdater**.**checkForUpdatesAndNotify 这个方法的时候,该方法会请求本地服务器的两个接口:

  1. 第一个接口主要是获取安装包的请求路径
js 复制代码
server.on("request", (request: IncomingMessage, response: ServerResponse) => {
  const requestUrl = request.url!!
  if (requestUrl === "/") {
    const data = Buffer.from('{ "url": "${getServerUrl()}${fileUrl}" }')
    response.writeHead(200, {"Content-Type": "application/json", "Content-Length": data.length})
    response.end(data)
    return
  }
......
})
  1. 第二个接口就是直接响应安装包的内容
js 复制代码
let errorOccurred = false
import { createReadStream } from "fs"
response.on("finish", () => {
  try {
    setImmediate(() => server.close())
  }
  finally {
    if (!errorOccurred) {
      this.nativeUpdater.removeListener("error", reject)
      resolve([])
    }
  }
})
const readStream = createReadStream(downloadedFile)
response.writeHead(200, {
  "Content-Type": "application/zip",
  "Content-Length": updateFileSize,
})
readStream.pipe(response)

当安装包数据响应完车之后,就可以调用 autoUpdater 的 quitAndInstall 方法进行一个安装操作。

V8执行原理

编程语言,可以按照编译和解释分为两大类:

  • 编译执行语言:C、C++、汇编语言,这一类语言都需要将开发者编写的代码编译为二进制代码,并且这些二进制代码是特定于操作系统和硬件架构,也就是说,这里所得到的二进制代码(机器码)是不跨平台。但是这种编译型语言的优点也很明显,能够提供更高的执行效率和更好的性能优化。
  • 解释执行语言:Python、Ruby、PHP,这一类语言都是解释执行语言,这一类语言在执行的时候,需要一个解释器,解释器的工作是在程序运行的时候,实时的将代码转为机器码然后执行。解释型语言的优点在于提供了更好的跨平台性和灵活性。

关于 JavaScript 是属于哪一个类别,我们来看一下 V8 引擎是如何执行 JS 代码的,然后再下定论。

V8引擎在执行一段 JS 代码之前,会做 3 件事情:

  1. 初始化内存中的堆栈结构:在 V8 里面有自己的堆空间和栈空间的设计,当代码运行的时候,会产生一些数据,这些数据就是存储在堆栈空间里面的,因此在执行代码之前,首先需要完成堆栈结构的初始化。
  2. 初始化全局环境:这一步主要就是对一些全局变量、工具函数进行一个初始化操作。
  3. 初始化消息循环:这一步主要是对象消息驱动器以及消息队列进行一个初始化操作。

上面这 3 项任务结束之后,V8引擎就可以执行 JS 代码了。

当 V8 引擎拿到一段待执行的 JS 代码之后,对于 V8 引擎来讲,这段 JS 代码就是一个很长字符串。

接下来 V8 引擎就会根据拿到的这个代码字符串生成一个抽象语法树。

有了抽象语法树之后,下一步,V8 引擎会为程序中的变量生成作用域,V8引擎在执行前面的 JS 代码的时候,会用到三个变量:

  1. 一个是临时变量 .result,用于存储返回值
  2. 用户使用 let 关键字生命的 param 变量
  3. 动态全局变量 console

这些变量都包裹在全局作用域 global 里面的。

接下来继续往后面走,V8 引擎会将之前得到的抽象语法树转为字节码,在字节码中,能够看到诸如 Add、Star、LdaXXX 之类的指令,这些指令都是在操作寄存器。

解释器常见的有两种架构:

  • 基于栈的解释器:会将中间数据存放到栈中
  • 基于寄存器的解释器:会将中间数据存放到寄存器中

V8 很明显是基于寄存器的解释器。

另外,在 V8 中,如果V8解释器发现某段代码被反复执行的时候,V8 的监控器就会将这段代码标记为热点代码,之后会将热点代码将给编译器进行优化。

例如我们写一个无意义的 for 循环,V8 引擎在解释执行的时候,就会发现这段代码是可以优化的,优化的时候会提供优化信息:

  • small function:代表优化的原因
  • using TurboFan OSR:代表的是 V8 所使用的优化引擎
  • took 1.026 :优化所耗费的时间

V8 引擎会针对不同类型的代码执行不同的优化手段,并且针对一些热点代码,直接编译为机器码。

V8 引擎内部既内嵌了编译器,又内嵌了解释器,因此 V8 同时具备两种能力:编译执行的能力和解释执行的能力。

这也是目前解释性语言比较流行的一种方式:即时编译(JIT)技术,这种技术可以在运行时将热点代码编译为机器码,从而提供执行效率。

垃圾回收原理

在 V8 引擎内部所使用的垃圾回收机制,是一种被称之为"代回收"的机制。

这种机制的特点就是会将内存分为两个生代:

  • 新生代
  • 老生代

不同的生代,所分配到的内存大小是不一样,另外根据不同位数的操作系统,内存大小的分配也不一样。

默认情况下:

  • 32位系统
    • 新生代内存所分配的大小为 16 MB
    • 老生代内存所分配到的大小为 700 MB
  • 64位系统
    • 新生代内存大小为 32 MB
    • 老生代内存大小为 1.4 GB

新生代

新生代所存储的对象,都是**生命周期比较短的对象**,分配内存也比较容易,只需要保存一个指向内存空间的指针即可,根据分配对象的大小,然后去递增指针就可以了。在新生代内存中,当存储空间快要满了的时候,就会进行一次垃圾回收。

新生代的这种垃圾回收算法,有一个弊端,就是只能使用新生代存储空间的一半,但是由于这个算法大量的使用了指针操作和批量处理内存操作,所以这个算法的效率是非常高的,这是一个典型的牺牲空间去换取时间的算法。

老生代

当一个对象经过多次复制仍然存活时,或者新生代空间使用超限时,对象就会被认为是一个**生命周期较长的对象**,那么这种对象就会被移动到老生代里面去。

当对老生代的对象执行垃圾回收的逻辑的时候,V8引擎会遍历老生代里面的对象,判断是否存在引用,如果存在引用,则做好标记,遍历完成后将没有被标记到的对象清除掉,这就是老生代的标记清除垃圾回收算法。

整体来讲,老生代的垃圾回收算法相比新生代的垃圾回收算法,效率要低很多,但是空间方面相比新生代要高很多。

判断对象是否存在引用

无论是新生代的垃圾回收,还是老生代的垃圾回收,都会存在判断对象是否存在引用的操作,因为只有没被引用的对象,才会被垃圾回收器所回收。

判断的工作是递归的遍历根对象上的所有属性以及属性的字属性,看是否能够访问到这个对象:

  • 如果能够访问到,则说明该对象还存在引用
  • 如果不能够访问到,那么说明这个对象可以被垃圾回收器回收

在 JavaScript 里面,有 3 种类型的根对象:

  1. 全局的 window对象
  2. 文档 DOM 树
  3. 存放在栈上的变量:位于正在执行的函数内

Node.js查看内存分配情况

可以通过 process 的 memoryUsage 方法来进行查看

该方法会会返回一个对象,该对象包含这么一些属性:

  • rss(resident set size):所有内存占用,包括指令区和堆栈。
  • heapTotal:V8引擎可以分配的最大堆内存,包含下面的heapUsed。
  • heapUsed:V8引擎已经分配使用的堆内存。
  • external:V8引擎管理C++对象绑定到JavaScript对象上的内存。

另外,开发者在主进程启动之初,app ready 之前,是可以扩大 Node.js 的堆内存的。

注意,在扩展堆内存的时候,扩展的值需要权衡,不能太高,也不要太低:

  • 如果设置得比较低,那么当堆内存消耗超出了限制,就会导致进程崩溃,从而整个应用也就崩溃了
  • 如果设置得太高,那么就会出现操作系统内存不足。

本文所有源码均在:github.com/Sunny-117/e...

「❤️ 感谢大家」

如果你觉得这篇内容对你挺有有帮助的话: 点赞支持下吧,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。觉得不错的话,也可以阅读 Sunny 近期梳理的文章(感谢掘友的鼓励与支持 🌹🌹🌹):

我的博客:

Github: https://github.com/sunny-117/

前端八股文题库: sunny-117.github.io/blog/

前端面试手写题库: github.com/Sunny-117/j...

手写前端库源码教程: sunny-117.github.io/mini-anythi...

热门文章

专栏

相关推荐
喵叔哟25 分钟前
重构代码之取消临时字段
java·前端·重构
还是大剑师兰特1 小时前
D3的竞品有哪些,D3的优势,D3和echarts的对比
前端·javascript·echarts
王解1 小时前
【深度解析】CSS工程化全攻略(1)
前端·css
一只小白菜~1 小时前
web浏览器环境下使用window.open()打开PDF文件不是预览,而是下载文件?
前端·javascript·pdf·windowopen预览pdf
方才coding1 小时前
1小时构建Vue3知识体系之vue的生命周期函数
前端·javascript·vue.js
阿征学IT1 小时前
vue过滤器初步使用
前端·javascript·vue.js
王哲晓1 小时前
第四十五章 Vue之Vuex模块化创建(module)
前端·javascript·vue.js
丶21361 小时前
【WEB】深入理解 CORS(跨域资源共享):原理、配置与常见问题
前端·架构·web
发现你走远了1 小时前
『VUE』25. 组件事件与v-model(详细图文注释)
前端·javascript·vue.js
Mr.咕咕1 小时前
Django 搭建数据管理web——商品管理
前端·python·django