webpack插件 vuecli-electron-bytenode-webpack-plugin 中文文档

这里是:webpack插件 vuecli-electron-bytenode-webpack-plugin 中文文档。


1 简介

1.1 背景介绍

随着electron框架的广泛应用,应用程序的安全性和性能正在逐渐受到重视。把js文件编译成字节码,不仅使得逆向分析难以进行,也可以省去V8的字节码编译步骤,可能带来一定的加载性能提升。

vuecli3-electron-bytenode-webpack-plugin是一个发布在npm和yarn上的webpack插件,致力于在构建流程中自动且可靠地进行上述工作,受到electron-bytenode-webpack-plugin的启发。我在其基础上增加了对vue-cli的支持,以及更多、更灵活的配置项,使得开发者可以根据自己的需求进行定制。


1.2 适用条件

以下条件同时满足,是本插件可以正常发挥作用的充分不必要条件:

  • 最终代码运行环境:Electron,且渲染进程开启了Node.js环境集成

  • 构建工具:Webpack

  • 脚手架:Vue-cli


1.3 为什么我要开发这个插件?

虽然已经有了electron-bytenode-webpack-plugin这个先例,但是这个插件(以及其他同类插件)依然存在以下缺点:

  • 无法与Vue-cli兼容
  • 无法处理使用webpackChunkName标记的懒加载模块
  • 可配置性差
  • 作者已经不维护
  • 构建产物较大,冗余度高

2 使用

2.1 安装

在你的项目根目录中执行以下命令:

1
npm install vuecli-electron-bytenode-webpack-plugin

2.2 引入

在你的项目配置文件中(一般是vue.config.js),引入本插件。

1
2
3
4
5
6
7
8
9
10
11
// ./vue.config.js
const { defineConfig } = require('@vue/cli-service')
const BytenodeWebpackPlugin = require('vuecli-electron-bytenode-webpack-plugin')

module.exports = defineConfig({
// ...
configureWebpack: (config) => {
// ...
config.plugins.push(new BytenodeWebpackPlugin({/*options*/}))
}
})

2.3 配置

按照第三节的说明,进行配置。其中lazyBundleNames必须填写。


2.4 启动构建流程

配置好之后就可以使用了。默认只会在production环境下进行字节码的编译。

1
npm run electron:build

3 配置API

按照如下的配置格式来进行插件配置:

1
2
3
4
5
6
7
8
9
// 一个配置的示例
config.plugins.push(new BytenodeWebpackPlugin({
lazyBundleNames: ['settings'],
environments: ["production", "test"],
logLevel: 'error',
keepSource: true,
compileAsModule: false,
// ...
}))

3.1 lazyBundleNames

  • 描述:懒加载chunk的名称。为保证所有.jsc模块的正常引入,务必全部列出来。比如,举一个Vue项目中最常见的例子:在用户的router.ts内,下面代码使用了特殊注释/* webpackChunkName */进行模块的拆分,会把名字为settings的组件从app.jsc中拆分为一个独立的单文件settings.jsc,那么你在配置本插件的时候就要在lazyBundleNames里面插入'settings'
1
2
3
4
5
6
7
8
9
// 用户的router.ts
{
path: '/settings',
name: 'settings',
// 路由级代码拆分
// 为这个路由生成一个单独的chunk(settings.[hash].js)
// 当访问该路由时,该chunk是懒加载的
component: () => import(/* webpackChunkName: "settings" */ '../views/Settings/SettingsView.vue')
},
  • 类型:Array<string>
  • 默认值:[]
  • 示例:
1
2
3
{
lazyBundleNames: ["store", 'settings', 'about']
}

3.2 environments

  • 描述:指定本插件在何种process.env.NODE_ENV下运行。若不满足匹配条件,本插件不会在apply钩子被调用之后做任何事情。
  • 类型:Array<string>
  • 默认值:["production"]
  • 示例:
1
2
3
{
environments: ["production", "test"]
}

3.3 compileAsModule

  • 描述:是否把.jsc文件编译成模块。建议不要随便乱动,保留默认值true
  • 类型:boolean
  • 默认值:true

3.4 keepSource

  • 描述:是否在构建产物中保留未被编译成字节码的代码。由于保留了源码引用,开启这一配置项之后Function.prototype.toString就能正常使用了。
  • 类型:boolean
  • 默认值:false

3.5 logLevel

  • 描述:控制日志等级。本插件运行过程中,会向控制台输出日志信息,使用这个配置项可以选择只输出大于特定级别的日志,有助于在不同阶段或需求下更清晰地查看日志信息。
  • 类型:'debug' | 'info' | 'warning' | 'error' | 'silent',从左至右等级逐渐升高,调试信息的数量逐渐减少
  • 默认值:'info'
  • 示例:
1
2
3
{
logLevel: 'error'
}

4 实现原理

4.1 核心原理

本插件主要依赖bytenode模块,借助vm.Script().createCachedData()获得V8字节码缓存,经过一些二次处理(如html内容修改、字节码模块化包装、loader代码嵌入等)后,将生成的内容emit到最终的构建结果里。


4.2 执行流程

4.2.1 初始化。webpack构建流程启动后,本插件会在apply(compiler)中注册compiler.hooks.emit钩子。当构建进行到输出asset到output目录的阶段时,这个钩子被触发,劫持当前compilation,将assets列表中符合字节码编译条件的js或html文件写入到指定目录(如果这个目录中已经存在文件,会先将其清空),并创建资源列表raw_assets.json。随后创建一个nodejs子进程,执行electron cli.js命令。这个命令会启动一个完整的electron环境,为接下来的字节码编译做好准备。

4.2.2 字节码编译。根据资源列表raw_assets.json,遍历指定目录,逐个编译文件,将编译结果同样写入到这个目录中,并创建编译结果列表compiled_assets.json。整个编译过程都是在electron环境中完成,由渲染进程的V8引擎负责字节码的转换。

4.2.3 模块包装。对字节码使用Module.wrap()进行包装,生成nodejs模块。

**4.2.4 ** 注入运行时代码。注入运行时代码,主要是注入loader代码。修改html中的script标签内容,使得字节码文件能够像普通JS文件一样被正常引入。对于使用webpackChunkName标记的懒加载模块,本插件提供了特殊支持,替换为loader代码,把js文件作为跳板去加载字节码。

bytenode是一个特殊的node模块,因为它既是构建时的编译工具,又是运行时的依赖库。这说明,如果我只是想在运行时使用bytenode来加载字节码文件,我其实是用不到bytenode的编译功能的。所以,我对运行时注入的bytenode模块进行了定制裁剪,去除了编译功能,保留了字节码加载功能,并且使用Uglify.js进行压缩。这一套操作下来,构建产物的大小从20+KB降为3KB左右。

注入的bytenode模块使用了IIFE进行包裹(~function(){/*裁剪和压缩过的bytenode模块*/}()),不会污染全局作用域


5 注意事项

5.1 兼容性

与常见的JavaScript源码不同,v8的字节码属于js引擎的内部实现(internal implements),没有相应的规范来约束,往往与特定的平台或操作系统相关,而且不同版本的v8所生成的字节码也不完全一致。例如,如果你的App是在AMD或ARM平台的自动构建服务器上完成构建和分发的,那么可能没办法在用户的Intel设备上正常运行。注意,我说的是“可能”,因为影响兼容性的因素很多、很复杂,你需要自己去做实验。实践才是检验真理的唯一标准

下面这张图,展示了不兼容的字节码文件,被V8引擎拒绝执行的情景:

一个典型的翻车场面

所以,如果你想提高构建平台与目标平台的兼容性,请确保以下因素一致或者尽可能接近:

  • Node.js版本
  • CPU架构、指令集
  • 操作系统
  • Electron版本(虽然本插件编译字节码时使用的electron环境与运行时一致,理论上electron版本不会影响兼容性,但是我在实际项目中还是遇到了不兼容问题。我最后通过降低并锁定electron版本到v27.1.2解决了问题。)

另外,如果你使用Github Actions,你可以在工作流脚本里配置jobs.<job_id>.runs-on指定容器的操作系统和CPU架构。这也许对提高兼容性有所帮助。详戳↓

定义将在工作流程中处理作业的计算机类型


5.2 开启node集成(nodeIntegration)

我们知道,Electron创建窗口时提供了nodeIntegration选项,决定渲染进程是否可以使用node API。然而本插件的注入代码高度依赖完整的node环境,如果主进程在创建渲染进程时设置了nodeIntegration: false,那么渲染进程内就完全无法进行字节码模块的加载,本插件将起不到任何正面作用。所以,请开启node集成。当然,这么做破坏了上下文保护机制,必定会导致额外的安全问题,需要你自己去做出权衡。


5.3 Function.prototype.toString问题

Any code depends on Function.prototype.toString function will break, because Bytenode removes the source code from .jsc files and puts a dummy code instead. See #34. For a workaround, see #163

本插件依赖Bytenode,借助new vm.Script执行.jsc文件。new vm.Script强制要求传入源代码作为第一个参数,因此这里Bytenode直接向其传递若干零宽度空格(’\u200b’)糊弄过去了:

1
2
3
4
5
let dummyCode = '';
if (length > 1) {
dummyCode = '"' + '\u200b'.repeat(length - 2) + '"'; // "\u200b" Zero width space
}
const script = new vm.Script(dummyCode, { cachedData, filename });

这就导致了Function.prototype.toString无法正常执行。请检查你的代码以及production依赖中是否调用Function.prototype.toString方法,若存在这个情况,请将这个方法覆写。(有一些库会调用toString来判断一个function是不是class的构造函数。)