Skip to content
On this page

打包篇-应用程序生产环境构建


前言

截至目前,我们已将整个简历平台开发完毕。接下来我们实现应用程序的生产环境打包构建。相对于第十四章而言,本章节的相关配置更为全面与详细。如果你对本章节内容兴趣不大,可以快速阅读或跳过。

本章节的主要目的是:

  • 实现 Electron 生产环境的构建打包
  • 实现 React 生产环境的构建打包
  • 通过 Electron-Builder 打包生成可执行文件
  • 解决遇到的一系列问题
  • 生成不同平台的可执行文件

同时此配置将会是后续 Webpack 打包优化Electron 打包体积优化的铺垫,接下来的两章将以此配置进行优化。

搞清楚 Electron 与 React 的关系

此关系已在💥 第四章-开发前必读!Electron 与 React 的关系进行说明,大家先去搞清楚关系之后(阅读时长 3 分钟),再返回看继续阅读。

本地开发

当我们在本地开发时,运行的脚本命令是:

javascript
npm run start:main // 运行主进程
npm run start:render // 运行渲染进程

react 通过 webpack-dev-server 起了一个本地服务,我们通过 http://127.0.0.1:7001 就能访问我们的站点。Electron 是通过 BrowserWindow 创建了一个浏览器窗口,此窗口通过 loadURL 加载了我们的地址(你可以理解成 webview 形式),从而显示我们的网页。

javascript
// 创建浏览器窗口
const mainWindow = new BrowserWindow({
  width: 1200,
  height: 800,
  webPreferences: {
    devTools: true,
    nodeIntegration: true,
  },
});

if (isDev()) {
  // 开发环境
  mainWindow.loadURL(`http://127.0.0.1:7001`);
} else {
  // 生产环境
  mainWindow.loadURL(`file://${path.join(__dirname, '../dist/index.html')}`);
}

打包构建

当我们本地开发完成之后,需要打包上线,需要跑什么命令?不要想太复杂。它们也是分开打包的

javascript
npm run build:main // 打包主进程(对 app/main/electron.ts进行打包 )
npm run build:render // 打包渲染进程(也就是对 React 进行打包)

打包之后,我们 dist 目录就会存在相应的资源文件。如 index.html、electron.js 等。

两者关系

提问:抛开 Electron,我们常规的 React 项目,在打包之后,如何运行?

我们是通过点击 dist 下的 index.html 就能在浏览器页面中打开看效果(如果你发现没效果,请确保是以相对路径加载资源文件)但如果结合了 Electron,那么此时点击 dist 下的 index.html,在浏览器中打开,一般都会出错的。

如何理解?Electron 它内置了 Chromium 和 Node,试想一下,你能在 Chrome 浏览器的控制台中输出 process 吗?并不能,它会报错。

image.png

但如果你是通过 Electron 的 BrowserWindow 创建的浏览器窗口,去打印 process,就能显示内容

image.png

这也是为什么 Electron 项目中 dist 下的 index.html 不能直接在 Chrome 浏览器运行,因为你的代码,可能用到了 Electron API、Node API,Chrome 浏览器无法识别这是什么东西。只有通过 Electron 生成的浏览器窗口,LoadURL 加载此页面,才能正常运行。

通过前面的代码可以看到,在生产环境下,我们 LoadURL 的是 dist 下的 index.html

javascript
if (isDev()) {
  // 开发环境
  mainWindow.loadURL(`http://127.0.0.1:7001`);
} else {
  // 生产环境
  mainWindow.loadURL(`file://${path.join(__dirname, '../dist/index.html')}`);
}

搞清楚了他们的关系之后,接下来该上主菜了。

🔨 动手实践-打包构建

搞清楚上述远离之后,我们只需要分别实现 Electron 打包和 React 打包即可,接下来,让我们一步步动手实践。

第一步:重新划分 Webpack 目录

回过头去看我们的 webpack 目录,存在三个文件

image.png

但这是我们开发环境下所需的配置,为此我们需要新增生产环境下打包构建的配置。

我们删掉 webpack.base.js 文件,原因是:既然划分了主进程和渲染进程的分开打包,我认为没必要将两者的通用内容划分到一块。

我们把上面的三个文件都删除,重新来配置一下 Electron 与 React 的相关打包内容。记得看注释

🐂 Electron 方面

新增三个文件

  • webpack.main.base.js 基础配置
  • webpack.main.dev.base.js 开发环境下的配置
  • webpack.main.prod.base.js 生产环境下的配置
javascript
// webpack/webpack.main.base.js

const path = require('path');
const webpack = require('webpack');

module.exports = {
  entry: path.resolve(__dirname, '../app/main/electron.ts'),
  output: {
    filename: 'electron.js',
    path: path.resolve(__dirname, '../dist'),
  },
  target: 'electron-main',
  devtool: 'inline-source-map',
  resolve: {
    // 主进程不会存在 jsx、tsx,所以不用配置这些后缀
    // 至于为什么不需要,会在下一章节优化处说明
    extensions: ['.js', '.ts'],
  },
  module: {
    rules: [
      {
        test: /\.(js|ts)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
        },
      },
    ],
  },
  plugins: [
    // 用于打包后的主进程中正确获取__dirname
    new webpack.DefinePlugin({
      __dirname: '__dirname',
    }),
  ],
};

Electron 方面的基础配置已经配好,接下来我们修改一下开发环境生产环境的配置

javascript
// webpack/webpack.main.dev.js

const webpackMerge = require('webpack-merge');
const mainBaseConfig = require('./webpack.main.base.js');

const devConfig = {
  mode: 'development',
};

module.exports = webpackMerge.merge(mainBaseConfig, devConfig);
javascript
// webpack/webpack.main.prod.js

const webpackMerge = require('webpack-merge');
const mainBaseConfig = require('./webpack.main.base.js');

const prodConfig = {
  mode: 'production',
};

module.exports = webpackMerge.merge(mainBaseConfig, prodConfig);

🐷 React 方面

新增三个文件

  • webpack.render.base.js 基础配置
  • webpack.render.dev.base.js 开发环境下的配置
  • webpack.render.prod.base.js 生产环境下的配置
javascript
// webpack/webpack.render.base.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // 多入口,这在第十七章有讲解
  entry: {
    index: path.resolve(__dirname, '../app/renderer/app.tsx'),
    setting: path.resolve(
      __dirname,
      '../app/renderer/windowPages/setting/app.tsx'
    ),
  },
  output: {
    filename: '[name].[hash].js',
    path: path.resolve(__dirname, '../dist'),
  },
  resolve: {
    // 这里就需要 jsx 和 tsx 了
    extensions: ['.js', '.jsx', '.ts', '.tsx'],
    // 别名配置,在 Electron 中并未用到别名路径,所以拆到 React 这边的配置中
    alias: {
      '@assets': path.join(__dirname, '../', 'assets/'),
      '@src': path.join(__dirname, '../', 'app/renderer'),
      '@common': path.join(__dirname, '../', 'app/renderer/common'),
    },
  },
  target: 'electron-renderer',
  devtool: 'inline-source-map',
  module: {
    rules: [
      {
        test: /\.(js|jsx|ts|tsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
        },
      },
      {
        test: /\.(jpg|png|jpeg|gif)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: '[name]_[hash].[ext]',
              outputPath: 'images/',
            },
          },
        ],
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader', 'postcss-loader'],
      },
      {
        test: /\.less$/,
        exclude: /node_modules/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: {
                localIdentName: '[name]__[local]__[hash:base64:5]',
              },
            },
          },
          'postcss-loader',
          'less-loader',
        ],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../app/renderer/index.html'),
      filename: path.resolve(__dirname, '../dist/index.html'),
      chunks: ['index'],
    }),
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../app/renderer/windowPages/setting/index.html'),
      filename: path.resolve(__dirname, '../dist/setting.html'),
      chunks: ['setting'],
    }),
  ],
};

React 方面的基础配置已经配好,接下来我们修改一下开发环境生产环境的配置

因为我们在开发环境下,通过 webpack-dev-server 起了一个本地服务,所以我们的开发环境配置应为

javascript
// webpack/webpack.render.dev.js

const path = require('path');
const webpackMerge = require('webpack-merge');
const renderBaseConfig = require('./webpack.render.base.js');

const devConfig = {
  mode: 'development',
  devServer: {
    contentBase: path.join(__dirname, '../dist'),
    compress: true,
    host: '127.0.0.1', // webpack-dev-server启动时要指定ip,不能直接通过localhost启动,不指定会报错
    port: 7001, // 启动端口为 7001 的服务
    hot: true,
  },
};

module.exports = webpackMerge.merge(renderBaseConfig, devConfig);
javascript
// webpack/webpack.render.prod.js

const webpackMerge = require('webpack-merge');
const renderBaseConfig = require('./webpack.render.base.js');

const prodConfig = {
  mode: 'production',
};

module.exports = webpackMerge.merge(renderBaseConfig, prodConfig);

添加打包命令

前往 package.json,我们为其添加一下打包构建的脚本命令

json
"scripts": {
  // 本地开发
  "start:main": "webpack --config ./webpack/webpack.main.dev.js && electron ./dist/electron.js",
  "start:render": "webpack-dev-server --config ./webpack/webpack.render.dev.js",
  // 👇 新增的生产打包命令
  "build:main": "webpack --config ./webpack/webpack.main.prod.js",
  "build:render": "webpack --config ./webpack/webpack.render.prod.js",
},

最终我们的 webpack 文件夹是这样的

image.png

第二步:安装 electron-builder

这边我们通过 electron-builder 进行打包,根据官方文档,我们进行安装

npm install electron-builder --save-dev

image.png

第三步:添加打包命令进行打包

由于很多属性,一一细讲并不现实,小伙伴可以前往 electron-builder 官方文档 阅读。

通过官方文档的 快速上手,只需要添加对应的一些配置即可实现打包。

首先我们在 package.json 中添加 build 属性,紧接着添加 PC 打包构建命令

json
{
  "scripts": {
    // 👇 新增的PC构建打包命令
    // 直接生成一个安装完毕的程序,能直接运行,而不是安装包
    "pack": "electron-builder --dir",
    // 这个命令是生成真正的安装包
    "dist": "electron-builder"
  },
  // 👇 新增打包相关的应用信息
  "build": {
    "appId": "visResumeMook.ElectronReact", // 自定义 appId
    "productName": "VisResumeMook", // 打包之后的程序名
    "copyright": "Copyright © 2019 ${author}",
    // https://www.electron.build/configuration/contents.html#files
    // 打包的 app.asar 中包含哪些文件,到时候解压出来就是哪些文件
    "files": ["dist/**/*", "package.json", "node_modules/"],
    // 构建的可执行文件放在 package 目录下
    "directories": {
      "output": "package"
    }
  }
}

这时候我们就可以执行打包了。一个完整的打包构建命令为:

javascript
// 1. 删除上一轮打包的 dist 目录和 package 目录
rm -rf dist package
// 2. 构建打包Elctron
npm run build:main
// 3. 构建打包 React
npm run build:render
// 4. 通过 electron-builder 构建安装包
//(如果你的静态资源没发生改变,可以直接 pack,不用再重新打包主进程和渲染进程)
npm run pack

运行一下,发现报错了

image.png

我们将 electron 放到 devDependencies 中

json
{
  "dependencies": {
    // 👇 将其放在 devDependencies 中
    // "electron": "^11.1.1"
  },
  "devDependencies": {
    "electron": "^11.1.1"
  }
}

重新 install,再执行一下命令 npm run pack (因为这里我的静态资源没发生改变,可以直接 pack,不用再重新打包主进程和渲染进程)

执行结果还是出错,原因在于我们的应用入口文件写错了

image.png

我们检查一下 package.json 中的 main 属性,果然有问题,进行修改

json
{
  // 👇 修改成打包后的入口文件
  "main": "./dist/electron.js"
}

再试一次,npm run pack,发现这次没问题了。稳妥

image.png

进入到 package 目录下,找到 mac 目录,进入可以看到一个可执行文件

双击打开,发现好像有些问题

image.png

image.png

💥 What?问题很多

  • 怎么主题配色的功能消失了?换言之,读取的 appConfig 文件好像都不存在?正因为读不到,所以没有了主题列表

image.png

  • 点击进入简历制作 模块无反应?

image.png

  • 模版列表怎么都没图片了,我们从 assets/template 文件夹中读取的模版封面,怎么也不见了呢?

image.png

🌈 问题一个个定位解决

有意思,这才是进步,毕竟快速定位问题也是一种技能。

Question1: 主题列表消失

先来看第一个问题,主题配色的功能消失了?为什么没有主题列表?双击打开应用,我们进入切到开发者工具,看看控制台输出什么。

image.png

果不其然,在第十六章时讲到,我们的主题是通过读取 appConfig 目录下的 theme.config.json 文件构造出来的主题列表,appConfig 文件夹都找不到,怎么会有主题列表呢?

百思不得其解?怎么会没有 appConfig 目录?前往 dist 文件夹瞧一瞧,果然没有!

image.png

为什么会如此?要知道,Webpack 只会将你所需要的资源进行打包,换言之,凡是你通过 import xxx 的资源引入并使用,就会被打包到 dist 文件夹中。

纸上得来终觉浅,得知此事要躬行,我们来试一试。我们就在 app/renderer/app.tsx 根文件下,引入 assets/template/template1.jpg 文件(验证一下)

ts
import React from 'react';
import ReactDOM from 'react-dom';
import Router from './router';
import store from './store';
import { Provider } from 'react-redux';

// 👇 ⚠️ 这里只是引入 template1.jpg 图片
import TestWebpackJpg from '@assets/template/template1.jpg';

function App() {
  return (
    <Provider store={store}>
      <Router />
    </Provider>
  );
}

ReactDOM.render(<App />, document.getElementById('root'));

此时我们再重新打包一下

bash
// 1. 删除上一轮打包的 dist 目录
rm -rf dist
// 2. 重新打包 React
npm run build:render

小伙伴们猜一下,此时的 dist/images/ 目录下是否会出现这张图片呢?

让我来告诉你答案:并不会。你可以前往 dist 目录查看,你会发现这张照片并没有打进去。我们将代码稍作修改

ts
import React from 'react';
import ReactDOM from 'react-dom';
import Router from './router';
import store from './store';
import { Provider } from 'react-redux';

// 👇 ⚠️ 引入 template1.jpg 图片
import TestWebpackJpg from '@assets/template/template1.jpg';

function App() {
  // 👇⚠️ 打印这张图片
  console.log(TestWebpackJpg);

  return (
    <Provider store={store}>
      <Router />
    </Provider>
  );
}

ReactDOM.render(<App />, document.getElementById('root'));

此时我们再重新打包一下

bash
// 1. 删除上一轮打包的 dist 目录
rm -rf dist
// 2. 重新打包 React
npm run build:render

小伙伴们猜一下,此时的 dist/images/ 目录下是否会出现这张图片呢?多说无益,是骡子是马,拉出来溜一溜

image.png

普天同庆,我们是能在 dist/images/ 下发现这张图片的。说明 Webpack 只会将我们使用到的 import 资源打包到 dist 下,针对 appConfigassets/template 根本不会打包,因为我们是通过 NodeJS 的 fs 文件系统进行读取的,问题来了,如何让打包之后的 dist 也能有这些文件呢?

在网上搜索了一些教程,发现如脚手架创建出来的目录,会存在一个 public 文件夹存放静态资源,如果你将静态资源文件放入该 public 文件夹,webpack 将不会处理它,在你打包的时候,会将 public 文件夹直接复制一份到你构建出来的文件夹中。

所以我的解决方案就是:将所需的文件夹复制一份放在 dist 目录下,如何复制?在 webpack 官方文档中有提供一个插件,使用 copy-webpack-plugin就能实现我们的需求。

接下来让我们修改一下 webpack.render.base.js 文件

javascript
// webpack/webpack.render.base.js

/* eslint-disable @typescript-eslint/no-require-imports */
const path = require('path');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  plugins: [
    // 👇 通过该插件实现资源文件的拷贝
    new CopyWebpackPlugin({
      patterns: [
        {
          from: path.resolve(__dirname, '../assets'),
          to: path.resolve(__dirname, '../dist/assets'),
        },
        {
          from: path.resolve(__dirname, '../appConfig'),
          to: path.resolve(__dirname, '../dist/appConfig'),
        },
      ],
    }),
  ],
};

此时我们再重新打包一下

bash
// 1. 删除上一轮打包的 dist 目录
rm -rf dist
// 2. 重新打包 React
npm run build:render

可以看到这时候在 dist 目录下,已经有对应的文件夹目录。

image.png

这时候是不是重新用 electron-builder 构建一个包,就可以了呢?让我们试试。走一个完整的打包构建命令

javascript
// 1. 删除上一轮打包的 dist 目录和 package 目录
rm -rf dist package
// 2. 构建打包Elctron
npm run build:main
// 3. 构建打包 React
npm run build:render
// 4. 通过 electron-builder 构建安装包
//(如果你的静态资源没发生改变,可以直接 pack,不用再重新打包主进程和渲染进程)
npm run pack

构建完毕之后,前往查看发现还是不行,控制台报错

Uncaught (in promise) Error: ENOENT: no such file or directory, scandir '/Users/pengdaokuan/Desktop/pdk/visResumeMook/package/mac/VisResumeMook.app/Contents/Resources/assets/template'

有趣,这个路径不正确,我们所期望的是,这里的 assets/template 前面应该是带有一个 dist 目录的。让我们来看看这个安装包的相关代码,由于 electron 打包后的代码是压缩过后的,我们需要对其解压一下。我们到 package/mac/VisResumeMook.app/Contents/Resources 中找到 app.asar,对它进行解压

// 1. 全局安装
npm install -g asar
// 2. 到该目录下,正确目录自行拼写
cd package/mac/VisResumeMook.app/Contents/Resources
// 3. 解压
asar extract app.asar ./

image.png

事实证明是存在的,那到底是什么问题导致没有的呢?我们再注意看看报错信息,主要问题就是路径问题,我们通过断点来看看 appConfig 的绝对路径是什么。进入到控制台,找到 Sources ,然后对 useThemeActionHooks.ts 文件打上端点。

image.png

然后我们刷新一下页面,我们鼠标悬浮到 appPath 上,看看输出的是什么

image.png

好家伙,这路径很明显不正确,如果你还记得 getAppPath() 方法获取应用程序的路径,想必不会忘记我们是通过 app.getAppPath() 方法获取的,这个 API 得到的是在文件管理器中的应用程序路径。而我们期望得到的是带有 app.aras/dist 这样的一个路径。所以说通过这个 API 获取是不正确的,那该通过什么获取呢? 答案是 __dirname

我们在控制台打印一下 __dirname,看看结果是什么。

image.png

这时候再去找 dist 下的 appConfig 是不是就行了?所以问题切到了,如何得到正确的路径。我们肯定不愿再改 getAppPath() 方法,所以我们只需要改主进程中,ipc 响应的数据即可。

javascript
// app/main/electron.ts

// getAppPath() 方法主要是通过 ipc 通信,得到项目路径,我们将 __dirname 的路径返回
ipcMain.on('get-root-path', (event, arg) => {
  event.reply('reply-root-path', __dirname);
});

让我们走一个完整的打包构建命令

javascript
// 1. 删除上一轮打包的 dist 目录和 package 目录
rm -rf dist package
// 2. 构建打包Elctron
npm run build:main
// 3. 构建打包 React
npm run build:render
// 4. 通过 electron-builder 构建安装包
//(如果你的静态资源没发生改变,可以直接 pack,不用再重新打包主进程和渲染进程)
npm run pack

然后进入 package/mac 双击应用程序,然后看到首页就有简历列表啦~

image.png

Quesiton2: 简历模版列表没有封面

上一个问题解决了,这个问题应该也不难,我们进入到模版列表,发现还是没有封面,原因竟然是

Uncaught (in promise) Error: ENOENT, distassets/template not found in /Users/pengdaokuan/Desktop/pdk/visResumeMook/package/mac/VisResumeMook.app/Contents/Resources/app.asar

等等,这个路径有点问题,distassets 中间是不是少了个 /,前往 app/renderer/hooks,找到 useReadDirAssetsTemplateHooks.ts 文件进行修改

image.png

让我们走一个完整的打包构建命令

javascript
// 1. 删除上一轮打包的 dist 目录
rm -rf dist
// 2. 构建打包Elctron
npm run build:main
// 3. 构建打包 React
npm run build:render
// 4. 通过 electron-builder 构建安装包
//(如果你的静态资源没发生改变,可以直接 pack,不用再重新打包主进程和渲染进程)
npm run pack

进入 package/mac 双击应用程序,进去到模版列表页面,是没问题的。

image.png

此时再去点击简历制作,也是可行的。至此我们的打包问题已经得到解决。

第四步:Window & Mac 双管齐下

前面我们是根据文档,做了最为简单的配置,通过 electron-builder 官方文档,它声明

Without target configuration, electron-builder builds Electron app for current platform and current architecture using default target.

也就是说,如果你什么都不配置的情况下,会根据你的系统平台,给你打一个默认的包。但我们往往是希望构建时,能生成多平台的安装包。接下来让我们配置一下吧,更多属性可自行查阅文档

json
{
  "build": {
    "appId": "visResumeMook.ElectronReact",
    "productName": "VisResumeMook",
    "copyright": "Copyright © 2019 ${author}",
    // 包含的文件,这个在解压 asar 时可以看到源代码
    "files": ["dist/**/*", "package.json", "node_modules/"],
    // 生成的安装包输出到 package 文件夹
    "directories": {
      "output": "package"
    },
    "mac": {
      "target": ["dmg", "zip"],
      "category": "public.app-category.productivity"
    },
    "dmg": {
      // 这个是安装时的图标位置
      "contents": [
        {
          "x": 130,
          "y": 220,
          "type": "link",
          "path": "/Applications"
        },
        {
          "x": 410,
          "y": 220,
          "type": "file"
        }
      ]
    },
    "win": {
      "target": ["msi", "nsis"]
    }
  }
}

让我们走一个完整的打包构建命令

javascript
// 1. 删除上一轮打包的 dist 目录和 package 目录
rm -rf dist package
// 2. 构建打包Elctron
npm run build:main
// 3. 构建打包 React
npm run build:render
// 4. ⚠️ 这里是要执行 dist,生成真正的安装包
npm run dist

经过一段时间等待,我们可以看到打包完成

image.png

让我们看看 package 下有什么文件

image.png

有一个 zipdmg,我是 Mac 电脑,所以我双击打开 dmg 的安装包进行安装

image.png

安装之后就能愉快使用啦

image.png

总结

本章节内容特别多,构建过程也遇到了许多的问题,阿宽认为最重要的是小伙伴们能搞清楚 Electron 与 React 的构建关系,搞清楚之后再通过 electron-builder 构建成安装包即可。

小伙伴们可以再回顾一下本章节的整体内容,代码可见👉 chapter-22-build

image.png

到此,我们的应用程序的构建已结束。但事情真的如此简单吗?