Appearance
业务篇-简历数据存档且自定义存储路径(多窗口)
前言
本章节将实现多 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.tsx
、index.tsx
、index.less
、index.html
,通过下图可以看到现在的文件结构
第二步:编写 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
文件呢?
第四步:编写主进程
我们新增了“应用设置”模块的窗口代码,也在 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
应用设置模块代码实现
通过上边实践,我们完成了“应用设置”窗口的创建,接下来我们来实现一下具体功能。先看一下原型稿
看起来并不麻烦, 我们先在 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;
刷新一下页面,可以看到效果
静态效果已被我们实现,接下来就是主逻辑:进行更换路径。通过 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
实现默认存储路径
上面我们是实现了“更改路径”的功能,但实际上,应用初次进入时,是默认不存在存储路径的,所以我们需要赋于默认的的存储路径值。接下来我们实现一下此功能点。
我们在 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 时,该数据是最新的即可。
最终的解决方案为:在应用设置窗口处理所有与存储路径相关的工作,主应用窗口就只需要读取全局配置表文件内容即可。
在修改更改存储路径上,也是通过读写操作全局配置表文件进行实现。
在主应用窗口中,导出 PDF 时,读取文件内容,得到存储地址。这里需要注意:如果用户压根就没打开应用设置窗口,进行存储路径的配置,那么需要给定一个默认地址。
在应用设置窗口读取默认配置并支持更改路径
我们前往 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 文件夹
最后
此章节以带着小伙伴实现 Webpack 入口与创建多个渲染进程及浏览器窗口,通过实现应用设置的存储路径,从而现象简历数据存储的最终效果。
本章节最为重要的在于主进程处的创建渲染进程,以及进程间通信,当然还有还堵全局配置文件的默认路径赋值与更改路径,章节篇幅有限,小伙伴们一定要结合线上代码进行配套学习。
如果您在边阅读边实践时,发现代码报错或者 TS 报错,那么小伙伴们可以根据报错信息,去线上看看相应的代码。
本章节的代码量相对较大,如果对本章节存在疑问,欢迎在评论区留言。