Skip to content
On this page

业务篇-简历数据存档且自定义存储路径(多窗口)


前言

本章节将实现多 Webpack 入口、多浏览器窗口进行实践设置模块,并实现自定义简历数据的本地存档,如果你对本章节内容兴趣不大,可以快速阅读或跳过。

明确定位

我们先来看看这个自定义存储路径功能,看起来它更像是一个完整独立的模块,对整个应用来讲,多它如虎添翼,少它也无伤大雅。

所以我期望:将来对该模块的迁移、改动,甚至删除,是不会影响主流程功能。

所以将它写成一个独立的模块看起来是个不错的选择,通过对市面上的一些 PC 应用做调研,我发现,应用设置都是新开一个窗口进行展示,这边我也采用此方式进行实现。

核心问题

通过前边的实践,我们在应用主进程中只创建了一个渲染进程且对应一个浏览器窗口。

如果你认真看第二章节,想必你还有印象:渲染进程的入口是一个 HTML 文件。多说无益,直接上代码(部分代码省略,只标注关键代码),看代码的注释

javascript
// webpack/webpack.render.dev.js
const devConfig = {
  // 👉 第一步:我们指定了入口文件,大部分情况下都是单入口文件,这里我们制定 app.tsx
  entry: {
    index: path.resolve(__dirname, '../app/renderer/app.tsx'),
  },
  // 👉 第二步:我们指定导出的文件名称和导出的文件路径
  output: {
    filename: '[name].[hash].js',
    path: path.resolve(__dirname, '../dist'),
  },
  // 👉 第三步:我们通过 dev-server 开了一个本地的服务,通过 http://127.0.0.1:7001/index.html 就能访问页面
  devServer: {
    host: '127.0.0.1',
    port: 7001,
  },
  // 👉 第四步:我们指定了自动生成 HTML 的模版,并且声明打包后的模版名称
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../app/renderer/index.html'),
      filename: path.resolve(__dirname, '../dist/index.html'),
      chunks: ['index'],
    }),
  ],
};

通过上述的代码解读,想必大家都能理解这段 Webpack 的配置以及最终的打包结果,最后的我们主进程中,只需要通过 loadURL('http://127.0.0.1/index.html') 加载链接即可。

问题随之而来,我们期望“应用设置”是一个独立的窗口,也就是再新增一个渲染进程。而渲染进程的入口是一个 HTML 文件,等价于我们在 Webpack 打包时,需要打包一份 setting.html,这样我们新增加的窗口只需要 loadURL('http://127.0.0.1/setting.html') 即可。

所以最核心的问题在于:如何实现 Webpack 的多入口打包。

开始实践

由于前期我们不断开发,项目已经有了雏形,此时不宜改动文件目录结构。照目前情况来看,最好的方式莫过于将新窗口的相关代码进行分割。

第一步:独立文件夹管理

我们在 renderer 文件夹下,新增一个文件夹,取名为:windowPages,意味着此文件夹是之后所有新增窗口的模块代码。

接着创建一个文件夹,取名为 setting,这是我们应用设置的代码文件夹,在其文件夹下追加 app.tsxindex.tsxindex.lessindex.html,通过下图可以看到现在的文件结构

image.png

第二步:编写 setting 相关代码

进入 renderer/windowPages/setting 文件夹,我们编写 app.tsx 文件

ts
// renderer/windowPages/setting/app.tsx
import React from 'react';
import ReactDOM from 'react-dom';
// 👇 引入Redux
import { Provider } from 'react-redux';
import store from '@src/store';
// 👇 应用设置的入口组件
import Setting from './index';

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

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

紧接着我们编写一下 index.tsx,简短的写下两行代码

ts
// renderer/windowPages/setting/index.tsx
import React from 'react';

function Setting() {
  return <div>应用设置-新窗口</div>;
}

export default Setting;

再修改一下我们应用设置的 HTML 入口模版代码

ts
// renderer/windowPages/setting/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>应用设置</title>
    <style>
      * {
        margin: 0;
      }
    </style>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

第三步:编写 Webpack 配置

我们进入 webpack/webpack.render.dev.js 文件,为其新增一个打包入口(部分代码省略)

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

const devConfig = {
  // 👇 这里定义多 entry
  entry: {
    index: path.resolve(__dirname, '../app/renderer/app.tsx'),
    setting: path.resolve(__dirname, '../app/renderer/windowPages/setting/app.tsx'),
  },
  // 👇 这里定义多个 htmlHtmlPlugin
  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'],
    }),
  ],
};

我们通过运行 npm run start:render 来瞧瞧,是不是会打包一份名为 setting.html 文件呢?

image.png

第四步:编写主进程

我们新增了“应用设置”模块的窗口代码,也在 Webpack 中定义了多入口,并且打包也存在此 HTML 文件,话不多说,新增窗口加载此 HTML 文件吧。

我们进入 app/main/electron.ts 中稍作修改

ts
// app/main/electron.ts

function createWindow() {
  // 创建主应用窗口
  const mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      devTools: true,
      nodeIntegration: true,
    },
  });

  // 创建应用设置窗口
  const settingWindow = new BrowserWindow({
    width: 720,
    height: 240,
    resizable: false, // 👈 我们设置该窗口不可拉伸宽高
    webPreferences: {
      devTools: true,
      nodeIntegration: true,
    },
  });

  if (isDev()) {
    mainWindow.loadURL(`http://127.0.0.1:7001/index.html`);
    settingWindow.loadURL(`http://127.0.0.1:7001/setting.html`);
  } else {
    mainWindow.loadURL(`file://${path.join(__dirname, '../dist/index.html')}`);
    settingWindow.loadURL(`file://${path.join(__dirname, '../dist/setting.html')}`);
  }
}

第五步:运行

接下来,通过运行应用,看看是否效果如我们所期望的一样

bash
npm run start:render
npm run start:main

最终效果,可以看到会存在两个窗口,并且新增的“应用设置”窗口如我们所期望的一样。可看此 👉 commit

image.png

应用设置模块代码实现

通过上边实践,我们完成了“应用设置”窗口的创建,接下来我们来实现一下具体功能。先看一下原型稿

image.png

看起来并不麻烦, 我们先在 windowPages/setting/index.tsx 中写下这段代码,样式代码忽略

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

import React, { useState } from 'react';
import './index.less';
import MyButton from '@common/components/MyButton';

function Setting() {
  const [resumeSavePath, setResumeSavePath] = useState('');

  const onSave = () => {};
  return (
    <div styleName="container">
      <p styleName="label">修改简历数据储存路径</p>
      <div styleName="input">
        <div styleName="value">{resumeSavePath || '当前存储路径为:'}</div>
        <div styleName="update-btn">更改路径</div>
      </div>
    </div>
  );
}

export default Setting;

刷新一下页面,可以看到效果

image.png

静态效果已被我们实现,接下来就是主逻辑:进行更换路径。通过 Electron 提供的 dialog 模块,我们可以实现文件的打开和保存,接下来试试.

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

import React, { useState } from 'react';
import './index.less';
import MyButton from '@common/components/MyButton';

function Setting() {
  const [resumeSavePath, setResumeSavePath] = useState('');

  const onChangePath = () => {
    // 1. 向主进程发送消息,因为 dialog 模块只能在主进程中调用
    ipcRenderer.send('open-save-resume-path', '');
    // 2. 监听从主进程发送回来的消息
    ipcRenderer.on('reply-save-resume-path', (event, arg: string[]) => {
      if (arg) {
        if (arg.length > 0) setResumeSavePath(arg[0]);
      } else {
        console.log('自定义存储路径失败');
      }
    });
  };

  return (
    <div styleName="container">
      <p styleName="label">修改简历数据储存路径</p>
      <div styleName="input">
        <div styleName="value">{resumeSavePath || '当前存储路径为:'}</div>
        <div styleName="update-btn" onClick={onChangePath}>
          更改路径
        </div>
      </div>
    </div>
  );
}

export default Setting;

如上图所示,我们定义了 onChangePath 方法,在该方法中通过 IPC 的方式进行通信。为什么进行通信?因为 dialog 模块只能作用于主进程,我们在渲染进程中是无法使用的。那么我们接着去修改一下主进程,添加下面这段代码

ts
// app/main/electron.ts

// 应用设置,保存自定义存储路径
ipcMain.on('open-save-resume-path', (event, arg) => {
  dialog
    .showOpenDialog({
      properties: ['openDirectory'],
    })
    .then((result) => {
      event.reply('reply-save-resume-path', result.filePaths);
    })
    .catch((err) => {
      event.reply('reply-save-resume-path', err);
    });
});

然后重新运行一下应用,此时点击“更改路径”,可以看到如我们预期一致。可看此 👉 commit

image.png

实现默认存储路径

上面我们是实现了“更改路径”的功能,但实际上,应用初次进入时,是默认不存在存储路径的,所以我们需要赋于默认的的存储路径值。接下来我们实现一下此功能点。

我们在 appConfig 文件夹下,新增全局配置项文件,暂且称为 global.config.json

json
// appConfig/global.config.json
{
  "name": "全局配置表",
  "resumeSavePath": ""
}

先来明确一下,resumeSavePath 数据在什么时候使用:

  • 作用于主应用窗口,在导出 PDF 时,以此地址为前提,进行简历数据文件的存储
  • 作用于应用设置窗口,用于存储地址的展示

既然两个浏览器窗口都需要此数据(两个渲染进程需要进行通信),那将此数据放在哪个渲染进程管理呢?其实不管放在哪个渲染进程管理,都逃不过主进程做消息中转。

第二章有提到,官方对于渲染进程与渲染进程之间的通信是不提供任何方式的,我们只能通过主进程进行中转,也就是主应用窗口先发一条消息给主进程,然后主进程再发给应用设置窗口,同时应用窗口在“更改路径”之后,也以同样的方式告知主应用窗口。

下面我简单给大家演示一下代码:(演示示例代码,注意看注释)

ts
// 渲染进程:主应用窗口,假设在路由组件

function Router() {
  useEffect(() => {
    // 1. 读取到默认的存储路径
    getAppPath().then((path: string) => {
      const defaultPath = `${path}resumeCache`;
      // 2. IPC 通信,告知主进程
      ipcRenderer.send('default-path_from_mainWindow_to_settingWindow', defaultPath);
    });
  });
}
ts
// 主进程

let currentSettingWindow: BrowserWindow;

function createWindow() {
  // 1. 创建主应用窗口
  const mainWindow = new BrowserWindow({});
  // 2. 创建应用程序窗口
  const settingWindow = new BrowserWindow({});
  currentSettingWindow = settingWindow;
}

// 3. 在主应用窗口获取默认路径之后,监听消息
ipcMain.on('default-path_from_mainWindow_to_settingWindow', (event, arg) => {
  console.log('从主应用窗口过来的默认路径:', arg);

  // 4. 主进程中转消息,同步到应用设置窗口
  currentSettingWindow.webContents.on('did-finish-load', () => {
    currentSettingWindow.webContents.send('default-path_from_settingWindow_to_mainWindow', arg);
  });
});
ts
// 渲染进程:应用设置窗口

function Setting() {
  useEffect(() => {
    // 1. 监听事件,获取默认的地址
    ipcRenderer.on('default-path_from_settingWindow_to_mainWindow', (event, arg: string) => {
      if (arg) {
        setResumeSavePath(arg);
      } else {
        console.log('自定义存储路径失败');
      }
    });
  }, []);
}

这只是初始进入应用时读取默认存储路径,接下来我们还有“更改路径”操作,这也是需要进行频繁通信的。所以这种方式固然可以实现,但实际上不合理。我们放弃这种方式,探索一条正确的道路。

我们思考一下,主应用窗口在什么时候用到这个字段数据?在导出 PDF 时;那需要实时数据吗?并不需要,只要保证在导出 PDF 时,该数据是最新的即可。

最终的解决方案为:在应用设置窗口处理所有与存储路径相关的工作,主应用窗口就只需要读取全局配置表文件内容即可。

image.png

在修改更改存储路径上,也是通过读写操作全局配置表文件进行实现。

image.png

在主应用窗口中,导出 PDF 时,读取文件内容,得到存储地址。这里需要注意:如果用户压根就没打开应用设置窗口,进行存储路径的配置,那么需要给定一个默认地址。

image.png

在应用设置窗口读取默认配置并支持更改路径

我们前往 renderer/hooks 文件夹下,新增 useGlobalConfigActionHooks.ts .ts 文件,该文件是对全局配置文件的读取的更新

ts
// renderer/hooks/useGlobalConfigActionHooks.ts

import path from 'path';
import fileAction from '@common/utils/file';
import { getAppPath } from '@common/utils/appPath';

/**
 * @description 读取全局配置文件的内容
 */
export function useReadGlobalConfigFile() {
  return () => {
    return new Promise((resolve: (values: { [key: string]: any }) => void, reject: (value: Error) => void) => {
      getAppPath().then((appPath: string) => {
        const jsonPath = path.join(appPath, 'appConfig/global.config.json');
        fileAction
          .hasFile(jsonPath)
          .then(async () => {
            const themeConfigValues = await fileAction.read(jsonPath, 'utf-8');
            resolve(JSON.parse(themeConfigValues));
          })
          .catch(() => {
            reject(new Error('appConfig does not exist !'));
          });
      });
    });
  };
}

/**
 * @description 读取配置文件的内容
 * @param {string} updateKey
 * @param {any} updateValues
 * @param {function} callback 回调函数
 */
export function useUpdateGlobalConfigFile() {
  const readGlobalConfigFile = useReadGlobalConfigFile();
  return (updateKey: string, updateValues: any, callback?: () => void) => {
    getAppPath().then((appPath: string) => {
      const jsonPath = path.join(appPath, 'appConfig/global.config.json');
      readGlobalConfigFile().then((values: { [key: string]: any }) => {
        if (values && !!Object.keys(values).length) {
          const nextConfigContent = {
            ...values,
            [`${updateKey}`]: updateValues,
          };
          fileAction.canWrite(jsonPath).then(() => {
            fileAction.write(jsonPath, nextConfigContent, 'utf-8').then(() => {
              callback && callback();
            });
          });
        }
      });
    });
  };
}

接着我们前往 renderer/windowPages/setting/index.tsx,修改一下我们的文件内容,注意看注释内容(伪代码)

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

import React, { useState, useEffect } from 'react';
import './index.less';
import { ipcRenderer } from 'electron';
import { getAppPath } from '@common/utils/appPath';
import { useReadGlobalConfigFile, useUpdateGlobalConfigFile,} from '@src/hooks/useGlobalConfigActionHooks';

function Setting() {
  const [resumeSavePath, setResumeSavePath] = useState('');
  // 👇 1. 引入 Hooks,进行读取文件内容和更新内容
  const readAppConfigThemeFile = useReadGlobalConfigFile();
  const updateGlobalConfigFile = useUpdateGlobalConfigFile();

  // 👇 2. 在 didMount 周期时,读取配置文件内容
  useEffect(() => {
    readAppConfigThemeFile().then((value: { [key: string]: any }) => {
      // 👇 2.1 如果存在默认路径,以此为主
      if (value?.resumeSavePath) {
        setResumeSavePath(value?.resumeSavePath);
      } else {
        // 👇 2.2 不存在默认路径,则设置默认路径并更新文件内容
        getAppPath().then((appPath: string) => {
          setResumeSavePath(`${appPath}resumeCache`);
          updateGlobalConfigFile('resumeSavePath', `${appPath}resumeCache`);
        });
      }
    });
  }, []);

  // 👇 3. 更改存储路径,发起 IPC 通信
  const onChangePath = () => {
    // 3.1 向主进程发送消息,因为 dialog 模块只能在主进程中调用
    ipcRenderer.send('open-save-resume-path', '');
    // 3.2 监听从主进程发送回来的消息
    ipcRenderer.on('reply-save-resume-path', (event, arg: string[]) => {
      if (arg) {
        // 3.3 设置最新存储路径,并更新文件内容
        if (arg.length > 0) {
          setResumeSavePath(arg[0]);
          updateGlobalConfigFile('resumeSavePath', arg[0]);
        }
      } else {
        console.log('自定义存储路径失败');
      }
    });
  };
}

export default Setting;

接着刷新一下页面,看看是否与我们期望一致?经过验证,是没问题的。接下来就是主应用在导出 PDF 时的工作处理了。

在主应用窗口导出时进行存储

接下来我们实现导出 PDF 时,以 json 文件形式存储我们的简历数据

文件格式以 年月日_姓名_学校_岗位_${UUID} 命名。

我们前往 renderer/container/resume/ResumeAction,修改index.tsx 中的导出回调函数

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

import { toPrintPdf } from '@common/utils/htmlToPdf';
import fileAction from '@common/utils/file';
import { createUID } from '@common/utils';
import { intToDateString } from '@common/utils/time';
import { getAppPath } from '@common/utils/appPath';
import { useReadGlobalConfigFile, useUpdateGlobalConfigFile } from '@src/hooks/useGlobalConfigActionHooks';

function ResumeAction() {
  const [showModal, setShowModal] = useState(false);
  const base: TSResume.Base = useSelector((state: any) => state.resumeModel.base);
  const work: TSResume.Work = useSelector((state: any) => state.resumeModel.work);
  const resume = useSelector((state: any) => state.resumeModel);

  // 👇 1. 引入 Hooks
  const readAppConfigThemeFile = useReadGlobalConfigFile();
  const updateGlobalConfigFile = useUpdateGlobalConfigFile();

  // 导出PDF
  const exportPdf = () => {
    toPrintPdf(`${base?.username}+${base?.school}+${work?.job}`);
    setShowModal(false);
    readAppConfigThemeFile().then((value: { [key: string]: any }) => {
      if (value?.resumeSavePath) {
        saveResumeJson(value?.resumeSavePath);
      } else {
        // 👇 2.2 不存在默认路径,则设置默认路径并更新文件内容
        getAppPath().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?.write(`${resumeSavePath}/${prefix}`, resume, 'utf8');
    } else {
      fileAction
        ?.mkdirDir(`${resumeSavePath}/resumeCache`)
        .then((path) => {
          if (path) fileAction?.write(`${path}/${prefix}`, resume, 'utf8');
        })
        .catch(() => {
          console.log('创建文件夹失败');
        });
    }
  };
}

export default ResumeAction;

不出意外的话,此时的你会报错,原因是找不到 fileAction.mkdirDir() 方法,原来在第七章节我们封装的 file.ts 文件中未支持 mkdirDir,我们前往 @common/utils/file.ts,添加一下代码。

ts
const fileAction = {
  /**
   * @description 创建文件夹
   * @param path 创建 /a/b/c,不管`/a` 和 /a/b 是否存在。
   * @returns {Promise}
   */
  mkdirDir: (path: string): Promise<string | undefined> => {
    return fsPromiseAPIs.mkdir(path, { recursive: true });
  },
};

接着我们刷新一下页面,看看效果如何。不存意外,会在你选中的文件夹下,存在一个 resumeCache 文件夹

image.png

最后

此章节以带着小伙伴实现 Webpack 入口与创建多个渲染进程及浏览器窗口,通过实现应用设置的存储路径,从而现象简历数据存储的最终效果。

本章节最为重要的在于主进程处的创建渲染进程,以及进程间通信,当然还有还堵全局配置文件的默认路径赋值与更改路径,章节篇幅有限,小伙伴们一定要结合线上代码进行配套学习。

如果您在边阅读边实践时,发现代码报错或者 TS 报错,那么小伙伴们可以根据报错信息,去线上看看相应的代码。

本章节的代码量相对较大,如果对本章节存在疑问,欢迎在评论区留言。