Skip to content
On this page

打包篇-生产环境疑难杂症的解决


前言

上一章节,我们将应用程序打包构建整完了,好像看起来没啥问题了,并非如此,还是存在许多的问题,接下来我们需要继续填坑了。如果你对本章节内容兴趣不大,可以快速阅读或跳过。

🔨 坑一:开发环境凉了

我们以 chapter-22-build 分支代码,继续往下开发,让我们先去开发环境试试吧

javascript
// 👇 记住,在进入开发环境时,先将 dist 目录删除
// 因为我们常规开发时,是不会去 build dist 目录的
rm -rf dist
npm run start:main
npm run start:render

此时看看会有什么问题

image.png

直接报错了,这是为什么呢?在本地开发时,我们通过 webpack-dev-server 起了一个本地服务

javascript
devServer: {
  contentBase: path.join(__dirname, '../dist'),
  compress: true,
  host: '127.0.0.1',
  port: 7001, // 启动端口为 7001 的服务
  hot: true,
}

在终端中我们也能看到输出的一些相关信息

image.png

webpack-dev-server 主要是启动了一个 express 的 HTTP 服务器,当原始文件发生改变之后,webpack-dev-server 会实时编译,但请注意,⚠️ 启动了 webpack-dev-server 后,dist 目录是看不到编译后的文件,实时编译后的文件都保存到了内存当中。所以你跑去 dist 目录下找,是找不到编译后的文件的。

按道理来讲,我们访问 dist 目录下的 assets/templateappConfig 都应该能读到数据的。那为什么会说找不到文件夹呢?

第一时间想到的就是文件夹拷贝问题,我们检查一下 webpack/webpack.render.base.js,该配置下,生产是没问题的,但在开发环境 dev-server 中存在问题。有没有可能是该插件,不支持 dev-server,我们前往官网找一下有没有相关 issues,还真找到一个 👉 Does not copy files to actual output folder when webpack-dev-server is used

一圈扫荡下来,大部分的回答是 2016 年、2017 年,还有说需要降版本的。这方案肯定不得行,回想一下,好像没打包构建之前,都能正常,这是为什么?因为获取应用路径被我们修改。

ts
// 原来开发环境能正常的应用路径
const ROOT_PATH = path.join(app.getAppPath(), '../');
ipcMain.on('get-root-path', (event, arg) => {
  event.reply('reply-root-path', ROOT_PATH);
});

// 打包构建时,我们将应用路径改成这样
ipcMain.on('get-root-path', (event, arg) => {
  event.reply('reply-root-path', __dirname);
});

上面的路径我们能在开发环境下正常,生产环境出现问题。下面的路径在开发环境上出现问题,生产环境下正常。

那就两者结合,各负责各的,我们将代码改造一下,前往 app/main/electron.ts 进行修改

ts
// app/main/electron.ts

const ROOT_PATH = path.join(app.getAppPath(), '../');
ipcMain.on('get-root-path', (event, arg) => {
  event.reply('reply-root-path', isDev() ? ROOT_PATH : __dirname);
});

让我们重新跑一下开发环境的命令吧

rm -rf dist
npm run start:main
npm run start:render

这时候是正常无问题的。那生产环境打包构建会不会有问题呢?走一个完整的打包构建命令

javascript
// 1. 删除上一轮打包的 dist 目录和 package 目录
rm -rf dist package
// 2. 构建打包Elctron
npm run build:main
// 3. 构建打包 React
npm run build:render
// 4. 通过 electron-builder 构建安装包
npm run dist

安装一下,经过验证,开发环境和生产环境均无问题!

🔨 坑二:切换主题,数据无法写入 theme.config.json

继上述打包构建之后,安装应用,此时选择切换主题,按道理来讲,应当存入 theme.config.json 文件,却得到异常报错,如下图

image.png

大家猜一猜,是什么原因?很简单,electron 不允许你对打包之后的代码进行修改,仔细想想,这也正常,你说像微信、QQ 这种应用安装包,它会允许你修改源代码文件?同样的,我们对打包构建之后的 dist/appConfig 目录进行增删改查操作,那肯定是无权限、不被允许的。

这里我给大家挖了一个坑,解铃还须系铃人,接下来让我们来填填坑~ 既然对于打包构建之后的文件都不能做增删改查操作,但我们确实有此需求,该如何实现?在 Electron 中,可以通过 app.getPath 去存储数据,我们来看看文档如何说的

image.png

也就是说,通过 app.getPath() API,我们可以得到用户在本设备上的一些路径,比如 userData 应用程序设置文件的文件夹路径,从而进行数据的存储,这是一次大改动,鉴于此次改动量有点大,一定要认真看

  • 第一步:在 app/main 目录下添加 userData,下面看代码注释
ts
import { app, ipcMain } from 'electron';
import path from 'path';
import fileAction from '@common/utils/file';

// 👇 1. 得到应用程序设置文件的文件夹,然后查看 appConfig 目录
const appConfigPath = path.resolve(app.getPath('userData'), 'appConfig');

// 👇 2 appConfig 文件夹是否可读
fileAction
  .canRead(appConfigPath)
  .then(() => {
    // 👇 2.1 appConfig 可读情况下,判断是否存在 theme.config.json
    fileAction.hasFile(`${appConfigPath}/theme.config.json`).catch(() => {
      // 2.1.1 不存在则默认创建
      createThemeConfigJson();
    });
    // 👇 2.2  appConfig 可读情况下,判断是否存在 global.config.json
    fileAction.hasFile(`${appConfigPath}/global.config.json`).catch(() => {
      // 2.2.1 不存在则默认创建
      createGlobalConfigJson();
    });
  })
  .catch(() => {
    // 👇 2.3 appConfig 文件夹不可读,说明不存在此文件夹,则新增文件夹
    fileAction.mkdirDir(appConfigPath).then(() => {
      // 2.3.1 并默认创建文件
      createThemeConfigJson();
      createGlobalConfigJson();
    });
  });

// 创建默认 theme.config.json
const createThemeConfigJson = () => {
  fileAction?.write(
    `${appConfigPath}/theme.config.json`,
    {
      name: '主题配置表',
      currentTheme: {
        id: 'green',
        fontColor: '#ffffff',
        backgroundColor: '#416f5b',
      },
      themeList: [
        { id: 'dark', fontColor: '#ffffff', backgroundColor: '#27292c' },
        { id: 'blue', fontColor: '#ffffff', backgroundColor: '#35495e' },
        { id: 'green', fontColor: '#ffffff', backgroundColor: '#416f5b' },
        { id: 'purple', fontColor: '#ffffff', backgroundColor: '#54546c' },
        { id: 'princess', fontColor: '#ffffff', backgroundColor: '#945454' },
      ],
    },
    'utf8'
  );
};

// 创建默认 global.config.json
const createGlobalConfigJson = () => {
  fileAction?.write(
    `${appConfigPath}/global.config.json`,
    { name: '全局配置表', resumeSavePath: '' },
    'utf8'
  );
};

// 👇 响应渲染进程想得到的 userData 路径,因为 app 模块只能在主进程中使用
ipcMain.on('Electron:get-userData-path', (event, arg) => {
  event.reply('Electron:reply-userData-path', app.getPath('userData'));
});

由于此文件中我们用到了 @common/utils/file 别名路径,所有需要在主进程的 webpack 做一些别名配置,我们修改 webpack/webpack.main.base.js,部分代码省略

javascript
module.exports = {
  resolve: {
    alias: {
      '@common': path.join(__dirname, '../', 'app/renderer/common'),
    },
  },
};
  • 第二步:引入 userData 文件

我们在主进程中引入此文件,修改 app/main/electron.ts

ts
// 将此文件引入
import './userData';
  • 第三步:前往 app/renderer/common/utils/appPath.ts 下,添加新的方法

添加新的获取 userData 路径方法

ts
/**
 * @description 获取应用 useData 路径
 * @returns {Promise<string>}
 */
export function getUserStoreDataPath(): Promise<string> {
  return new Promise(
    (resolve: (value: string) => void, reject: (value: Error) => void) => {
      ipcRenderer.send('Electron:get-userData-path', '');
      ipcRenderer.on('Electron:reply-userData-path', (event, arg: string) => {
        if (arg) {
          resolve(arg);
        } else {
          reject(new Error('项目路径错误'));
        }
      });
    }
  );
}
  • 第四步:将涉及到 appConfig 的文件都进行修改

我们去 app/renderer/hooks 下,找到 useThemeActionHooks.ts 文件修改,下边为伪代码

ts
// app/renderer/hooks/useThemeActionHooks.ts

// 👇 修改成获取 userData 路径的方法
import { getUserStoreDataPath } from '@common/utils/appPath';

/**
 * @description 读取配置文件的内容
 */
function useReadAppConfigThemeFile() {
  return () => {
    return new Promise((resolve: (values: { [key: string]: any }) => void, reject: (value: Error) => void) => {
      // 👇 这里改一下方法名
      getUserStoreDataPath().then((appPath: string) => {
        // ...
      });
    });
  };
}

/**
 * @description 更新配置表中的用户设置信息
 * @param {string} updateKey
 * @param {any} updateValues
 * @param {function} callback 回调函数
 */
function useUpdateAppConfigThemeFile() {
  const readAppConfigThemeFile = useReadAppConfigThemeFile();
  return (updateKey: string, updateValues: any, callback?: () => void) => {
    // 👇 这里改一下方法名
    getUserStoreDataPath().then((appPath: string) => {
      // ...
    });
  };
}

我们再去 app/renderer/hooks 下,找到 useGlobalConfigActionHooks.ts 文件修改,下边为伪代码

ts
// app/renderer/hooks/useGlobalConfigActionHooks.ts

// 👇 修改成获取 userData 路径的方法
import { getUserStoreDataPath } from '@common/utils/appPath';

/**
 * @description 读取全局配置文件的内容
 */
export function useReadGlobalConfigFile() {
  return () => {
    return new Promise((resolve: (values: { [key: string]: any }) => void, reject: (value: Error) => void) => {
      // 👇 这里改一下方法名
      getUserStoreDataPath().then((appPath: string) => {
        // ...
      });
    });
  };
}

/**
 * @description 读取配置文件的内容
 * @param {string} updateKey
 * @param {any} updateValues
 * @param {function} callback 回调函数
 */
export function useUpdateGlobalConfigFile() {
  const readGlobalConfigFile = useReadGlobalConfigFile();
  return (updateKey: string, updateValues: any, callback?: () => void) => {
    // 👇 这里改一下方法名
    getUserStoreDataPath().then((appPath: string) => {
      // ...
    });
  };
}

这边还需要对应用设置窗口初始化存储路径做一下修改,前往 renderer/windowPages/setting,修改一下 index.tsx 代码,部分代码省略

ts
// app/renderer/windowPages/setting/index.tsx

// 👇 修改成获取 userData 路径的方法
import { getUserStoreDataPath } from '@common/utils/appPath';

function Setting() {
  useEffect(() => {
    // 👇 读取配置文件内容
    readGlobalConfigFile().then((value: { [key: string]: any }) => {
      // 如果存在应用存储路径,则采用
      if (value?.resumeSavePath) {
        setResumeSavePath(value?.resumeSavePath);
      } else {
        // 否则获取 userData 路径,以 userData 路径为准
        // 更新 global.config.json 中的应用存储路径字段
        getUserStoreDataPath().then((appPath: string) => {
          setResumeSavePath(`${appPath}/resumeCache`);
          updateGlobalConfigFile('resumeSavePath', `${appPath}/resumeCache`);
        });
      }
    });
  }, []);
}

我们前面讲到,在导出 PDF 时,是通过读取 global.config.json 中的 resumeSavePath 字段,以此路径,将我们的简历数据文件写入该路径下的文件夹。

那么我们也需要对应做下修改,前往 renderer/container/resume/ResumeAction,修改一下 index.tsx 代码,部分代码省略

ts
// app/renderer/container/resume/ResumeAction/index.tsx

// 👇 修改成获取 userData 路径的方法
import { getUserStoreDataPath } from '@common/utils/appPath';

// 导出PDF
const exportPdf = () => {
  toPrintPdf(`${base?.username}+${base?.school}+${work?.job}`);
  setComponentVisible(false);
  readGlobalConfigFile().then((value: { [key: string]: any }) => {
    // 如果存在,以此路径进行简历数据文件的写入
    if (value?.resumeSavePath) {
      saveResumeJson(value?.resumeSavePath);
    } else {
      // 不存在默认路径(可能都没打开过应用设置窗口)
      // 则设置默认路径并更新文件内容
      getUserStoreDataPath().then((appPath: string) => {
        updateGlobalConfigFile('resumeSavePath', `${appPath}/resumeCache`);
        saveResumeJson(`${appPath}/resumeCache`);
      });
    }
  });
};

// 存储数据json
const saveResumeJson = (resumeSavePath: string) => {
  const date = intToDateString(new Date().valueOf(), '_');
  const prefix = `${date}_${base?.username}_${base?.school}_${work?.job}_${createUID()}.json`;
  // 如果路径中不存在 resumeCache 文件夹,则默认创建此文件夹
  if (resumeSavePath && resumeSavePath.search('resumeCache') > -1) {
    fileAction
      .canRead(resumeSavePath)
      .then(() => {
        fileAction?.write(`${resumeSavePath}/${prefix}`, resume, 'utf8');
      })
      .catch(() => {
        fileAction
          .mkdirDir(resumeSavePath)
          .then(() => {
            fileAction?.write(`${resumeSavePath}/${prefix}`, resume, 'utf8');
          })
          .catch(() => {
            console.log('创建文件夹失败');
          });
      });
  } else {
    fileAction
      .mkdirDir(`${resumeSavePath}/resumeCache`)
      .then(() => {
        fileAction?.write(
          `${resumeSavePath}/resumeCache/${prefix}`,
          resume,
          'utf8'
        );
      })
      .catch(() => {
        console.log('创建文件夹失败');
      });
  }
};
  • 第五步:删除 appConfig 文件夹

因为我们现在的 appConfig 文件夹是存储在应用程序设置文件中,我们可以将其删除。

  • 第六步:修改拷贝文件夹的代码

因为本地没有了 appConfig 文件夹,所以 copy-webpack-plugin 配置也需要做下修改。我们前往 webpack/webpack.render.base.js 做下更改

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

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'),
        // },
      ],
    }),
  ],
};

我们重新跑一下开发环境的命令,然后进行到制定的 userData 文件夹下,找到 Electron 文件夹,看看应用启动之后,会不会生成对应的 appConfig,并携带 theme.config.jsonglobal.config.json 文件

rm -rf dist
npm run start:main
npm run start:render

image.png

经过验证,是没问题的,小伙伴们记住了,这是 userData 的路径,根据你的平台设备,进入对应的路径

  • appData  每个用户的应用程序数据目录,默认情况下指向:
    • %APPDATA% Windows 中
    • $XDG_CONFIG_HOME or ~/.config Linux 中
    • ~/Library/Application Support macOS 中

生产环境打包构建会不会有问题呢?走一个完整的打包构建命令

javascript
// 1. 删除上一轮打包的 dist 目录和 package 目录
rm -rf dist package
// 2. 构建打包Elctron
npm run build:main
// 3. 构建打包 React
npm run build:render
// 4. 通过 electron-builder 构建安装包
npm run dist

打包之后,安装一下,打开应用,在应用启动之后,我们进入到 userData 路径,请注意,此时打包后的应用程序名称是 visResumeMook,我们进去看看

image.png

开发环境和生产环境均无问题!

这时候坑一自然而然也就没了,项目中的 appConfig 都删了,哪还会有拷贝失败的问题呢?

🔨 坑三:切换主题,下次进入居然不是上一轮的

每次打开应用,你会发现主题色都是黑色,并不是我所期望的上一轮配色。那为什么会是这样呢?我们来看看 app/renderer/hooks 下的 /useThemeActionHooks.ts 文件代码。

ts
function useSelectTheme() {
  const dispatch = useDispatch();
  return (themeConfigValues: any) => {
    // 👇 在 theme.config.json 存储到是 currentTheme 对象,而不是一个 id,需要改成这样
    // const prevTheme: string = themeConfigValues?.currentTheme || '';
    const prevTheme: TSTheme.Item = themeConfigValues?.currentTheme;

    let nextTheme: TSTheme.Item;

    if (themeConfigValues?.themeList.length > 0) {
      // 👇 并不是通过 id 去找,而是直接使用当前主题,需要改成这样
      // if (prevTheme) nextTheme = _.find(themeConfigValues?.themeList, { id: prevTheme }) || initTheme;
      if (prevTheme) nextTheme = prevTheme || initTheme;
    } else {
      nextTheme = initTheme;
    }
  };
}

在开发环境下验证一波,确实无误,打个包看看,走一个构建流程

javascript
// 1. 删除上一轮打包的 dist 目录和 package 目录
rm -rf dist package
// 2. 构建打包Elctron
npm run build:main
// 3. 构建打包 React
npm run build:render
// 4. 通过 electron-builder 构建安装包
npm run dist

🌈 无问题,稳妥

优化 4:生产环境下禁止拉伸窗口与进入开发者模式

我们进入到主进程 app/main/electron.ts 中,修改一下代码

ts
// 👇 将这个方法导出,因为我们在 customMenu 用到
export function isDev() {
  return process.env.NODE_ENV === 'development';
}

const mainWindow: MyBrowserWindow = new BrowserWindow({
  width: 1200,
  height: 800,
  resizable: isDev(), // 根据环境进行判断
  webPreferences: {
    devTools: isDev(), // 根据环境进行判断
    nodeIntegration: true,
  },
});

// 创建应用设置窗口
const settingWindow: MyBrowserWindow = new BrowserWindow({
  width: 720,
  height: 240,
  resizable: isDev(), // 根据环境进行判断
  show: false,
  frame: false,
  webPreferences: {
    devTools: isDev(), // 根据环境进行判断
    nodeIntegration: true,
  },
});

对应的,我们需要将菜单栏中的功能根据环境不同进行处理。

ts
import _ from 'lodash';
import { MyBrowserWindow, isDev } from './electron';
import { MenuItemConstructorOptions, shell, app, MenuItem, BrowserWindow } from 'electron';

const customMenu: (MenuItemConstructorOptions | MenuItem)[] = [
  // ...
  // ...
  {
    label: '视图',
    submenu: [
      // ...
      // 这里把 `切换开发者工具` 一栏,通过环境动态配置
    ],
  },
];

if (isDev()) {
  (customMenu[2]?.submenu as any).push({
    label: '切换开发者工具',
    role: 'toggleDevTools',
    accelerator: (() => {
      if (process.platform === 'darwin') {
        return 'Alt+Command+I';
      } else {
        return 'Ctrl+Shift+I';
      }
    })(),
    click: (item: any, focusedWindow: MyBrowserWindow) => {
      if (focusedWindow) {
        focusedWindow.webContents.openDevTools();
      }
    },
  });
}

export default customMenu;

这时候在开发环境和生产环境,效果截然不同~

最后

构建打包属实不易,这过程出现很多问题,但要感谢问题的出现,通过解决问题,我们才有进步。至此我们的打包构建告一段落, 👉 此章节的相关代码在此,建议配合线上代码,结合小册内容,动手实践。