electron
提供了一个简易、开箱即用的桌面端跨平台桌面GUI
开发方案,typescript
提供了静态类型检查,而react
可以简化和丰富界面交互,三者结合,可大大提示开发体验。在一些例子中,react
与electron
的结合一般是分离的,即页面布局交互作为单独的react
工程,作为一个http
服务启动,然后在electron
的入口文件中引入首页的URL
,这种方式要额外监听一个端口,且不够集成。因此,下面实现一种集成方案,直接在electron
中像引入普通html
文件一样引入react
文件,做到无缝集成。
初始化
typescript
不必说经过tsc
编译即可运行,但react
的tsx
文件无法直接编译,其编译过程需要用到插件且与ts
的编译有所差异,但最终目的都是将其编译为普通js
文件,因此,我们考虑通过引入自动化流程构建工具,手动编写编译规则和流程,完成ts
、tsc
以及样式文件(scss
等)等的编译。创建工程:
mkdir ElectronIntagration && cd ElectronIntagration && npm init
设计工程目录大结构如下:
ElectronIntagration
|-- src # 源代码目录
| |--- assets # 公共资源文件目录,如图片、字体等
| |--- components # 组建目录,存放react组建(*.tsx文件)
| |--- styles # 样式目录,存放样式文件,这里采用scss
| |--- html # html文件目录
| |--- main.ts # 入口文件
|
|-- dist # 编译目录,src下的代码编译至此目录
|-- pack # 打包输入目录,打包后的可执行文件输出至此目录
|-- gulpfile.js # 流程控制文件
|-- tsconfig.json
|-- tsconfig.build.json
|-- package.json
引入typescript
首先第一步肯定是引入typescript
配置,在根目录下新建tsconfig.json
配置文件,如下:
// tsconfig.json
{
"compilerOptions": {
"declaration": true, // 生成*.d.ts描述文件
"removeComments": true, // 移除注解
"target": "es5", // 编译目标
"sourceMap": true, // 生成代码地图
"outDir": "./dist", // 编译输出目录
"baseUrl": "./", // 根目录
"incremental": true, // 增量编译
"allowJs": true, // 运行js
"lib": [ // 声明内置依赖库
"dom",
"dom.iterable",
"esnext"
],
"skipLibCheck": true, // 跳过库检查
"esModuleInterop": true, // 开启es模块互操作,兼容CommonJS与ES module的库
"allowSyntheticDefaultImports": true, // 允许对不包含默认导出的模块使用默认导入
"strict": true, // 严格模式
"forceConsistentCasingInFileNames": true, // 强制文件名大小写敏感
"moduleResolution": "node", // 模块解析方案
"resolveJsonModule": true, // 解析json模块
"isolatedModules": true, // 将每个文件作为单独的模块
"jsx": "react", // 声明jsx模式
"strictNullChecks": false, // 严格空类型检查
"noImplicitAny": false // 隐式any声明,当编译器无法根据变量的用途推断出变量的类型,把变量类型默认为 any
},
"include": [
"src/**/*" // 编译目录
],
"exclude": ["node_modules", "dist", "pack"]
}
然后根目录下创建tsconfig.build.json
编译文件如下:
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "pack"]
}
最后安装相关依赖
yarn add typescript --dev
yarn add react
yarn add react-dom
yarn add @types/node --dev
yarn add @types/react --dev
yarn add @types/react-dom --dev
yarn add electron --dev
yarn add @types/electron --dev
创建页面文件
我们首先创建一个react
组件,作为我们的首页,在src/components
下新建app.component.tsx
,如下:
import React, { useEffect, useState } from 'react';
export default function App() {
const [counts, setCounts] = useState(0);
const [resetDisable, setResetDisable] = useState(true);
const increase = () => setCounts(ct => ct + 1);
const reset = () => setCounts(0);
useEffect(() => {
if (counts === 0 && !resetDisable) {
setResetDisable(true);
}
if (counts > 0 && resetDisable) {
setResetDisable(false);
}
}, [counts])
return (
<div>
<p>Clicked {counts} times ...</p>
<br/>
<button onClick={increase}>
Increase
</button> 
<button onClick={reset} disabled={resetDisable}>
Rest
</button>
</div>
)
}
然后在src/html
下新建一个main.html
文件,作为electron
引用的入口模板文件,如下:
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<link rel="stylesheet" href="../css/style.css" />
</head>
<body>
<div id="root"></div>
</body>
<script type="text/jsx">
import React from 'react';
import ReactDOM from 'react-dom';
import App from '../components/app.component';
window.onload = () => {
ReactDOM.render(
<React.StrictMode>
<App /> // 根组件
</React.StrictMode>
, document.getElementById('root')
);
}
</script>
</html>
创建Electron
入口文件
下面创建electron
入口文件,引入main.html
,在src
下创建main.ts
,如下:
// src/main.ts
// src/main.ts
import * as path from 'path';
import { app, BrowserWindow, Menu } from 'electron';
let mainWin: BrowserWindow = null;
/**
* 创建主窗口
*/
function createMainWin() {
mainWin = new BrowserWindow({
width: 1000, // 窗口宽度
height: 800, // 窗口高度
minWidth: 300, // 窗口最小宽度
minHeight: 500, // 窗口最小高度
webPreferences: {
nodeIntegration: true, // 开启node支持,在页面文件中可以执行node本地代码
defaultEncoding: 'utf8' // 默认编码
},
show: false // 不立即显示
});
Menu.setApplicationMenu(null); // 菜单,不定义菜单
mainWin.on('close', () => mainWin = null);
mainWin.once('ready-to-show', () => {
// 页面渲染完成再显示,优化启动时出现一段时间的白窗
if (mainWin !== null) {
mainWin.show();
}
});
mainWin.loadFile(path.join(__dirname, 'html', 'main.html')); // 加载首页页面
}
app.whenReady().then(createMainWin);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWin();
}
});
创建编译流程
现在程序入口与页面文件均已创建好,下一步就是运行起来,实际目的就是将src
目录下代码编译成node
可以执行的原生js
文件,我们采用gulp
流程构建工具,对src
目录下的内容进行分类编译,即分别对*.ts
、*.tsx
、*.less*
以及*.html
中的text/jsx
标签内内容进行编译,编译结果存在dist
目录,首先引入gulp
依赖:
yarn add gulp --dev
在工程根目录下创建gulpfile.js
,编写流程控制代码。
下面分别考虑各个文件类型的编译。
编译*.ts
编译ts
使用babel
进行,通过typescript
相关插件进行编译,安装依赖:
yarn add gulp-babel @babel/preset-env @babel/preset-typescript babel-plugin-transform-class-properties --dev
其中第一个依赖是添加gulp
对babel
的支持,第二个依赖是环境配置,会自动配置一些预编译环境,第三个依赖是ts
编译插件,第四个依赖是支持编译class
。编译流程如下:
// gulpfile.js
import * as path from 'path';
const babel = require('gulp-babel');
const distDir = path.join(__dirname, 'dist'); // 编译到的目录
function buildTs() {
return gulp.src('src/**/*.ts') // 编译目标
.pipe(babel({
presets: [
"@babel/preset-typescript",
["@babel/preset-env", {
targets: {
esmodules: true
}
}]
],
plugins: ["transform-class-properties"]
}))
.pipe(gulp.dest(distDir));
}
编译*.tsx
编译tsx
在ts
的基础上加入react
支持即可,加入如下依赖:
yarn add @babel/preset-react --dev
编译流程如下:
// gulpfile.js
const babel = require('gulp-babel');
function buildTsx() {
return gulp.src('src/**/*.tsx')
.pipe(babel({
presets: [
[
"@babel/env",
{
targets: {
esmodules: true,
node: true
},
modules: "auto"
}
],
[
"@babel/preset-react" // react 插件
],
"@babel/preset-typescript"
]
}))
.pipe(gulp.dest(distDir));
}
编译*.html
html
的编译只需关注text/jsx
标签内容即可,其它内容都是标准的原生html
标签,因此只需将text/jsx
中的内容编译为普通js
,添加如下依赖:
yarn add gulp-cheerio --dev
yarn add @babel/core --dev
gulp-cheerio
可以对html
进行解析,利用它可以读取并更改html
中的节点内容,@babel/core
可以将编译代码片段,将text/jsx
内容编译为普通js
。
编译流程如下:
// gulp.js
const babelCore = require('@babel/core');
const cheerio = require('gulp-cheerio');
function buildHtml() {
const jsxBabelOptions = {
presets: [
[
"@babel/env",
{
targets: {
esmodules: true,
node: true
},
modules: "auto"
}
],
[
"@babel/preset-react"
]
]
};
return gulp.src('src/**/*.html')
.pipe(cheerio(($, __) => {
$('script').each((__, element) => {
if (element.attribs && element.attribs.type === 'text/jsx') {
// 编译text/jsx节点
element.children.forEach(childer => {
if (childer.data) {
// 替换为编译后的js内容
const codeTransed = babelCore.transform(childer.data, jsxBabelOptions).code;
childer.data = os.EOL + codeTransed + os.EOL;
}
})
// 更改节点标签
element.attribs.type = 'text/javascript';
}
})
}))
.pipe(gulp.dest(distDir));
}
编译assets
目录
assets
目录存放图片等资源,不需要特殊处理,直接复制即可:
function buildAssets() {
return gulp.src('src/assets/**/*').pipe(gulp.dest(path.join(distDir, 'assets')));
}
编译styles
目录
styles
目录存放样式代码,若是css
,无需特殊处理,直接复制即可,若使用其它预处理语言,则应用相应的插件编译为css
,以编译less
的为例,添加less
编译支持:
yarn add gulp-less --dev
流程如下:
function buildCss() {
return gulp.src('src/**/*.css')
.pipe(gulp.dest(distDir));
}
function buildLess() {
return gulp.src('src/**/*.less')
.pipe(less()).pipe(gulp.dest(distDir));
}
最终流程
最后加一个清理函数,引入shell.js
,如下:
yarn add shelljs --dev
清理即清空dist
目录,如下:
const shell = require('shelljs');
function cleanDist(cb) {
if (fs.existsSync(distDir)){
// fs模块的unlink老是会报权限问题
shell.rm('-rf', path.join(__dirname, 'dist'));
}
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir);
}
cb();
}
至此,所有编译流程都已完成,最终将它们组合起来即可,最终的gulpfile.js
如下:
const path = require('path');
const fs = require('fs');
const os = require('os');
const shell = require('shelljs');
const gulp = require('gulp');
const babel = require('gulp-babel');
const babelCore = require('@babel/core');
const cheerio = require('gulp-cheerio');
const less = require('gulp-less');
const distDir = path.join(__dirname, 'dist')
function buildTs() {
return gulp.src('src/**/*.ts')
.pipe(babel({
presets: [
"@babel/preset-typescript",
["@babel/preset-env", {
targets: {
esmodules: true
}
}]
],
plugins: ["transform-class-properties"]
}))
.pipe(gulp.dest(distDir));
}
function buildTsx() {
return gulp.src('src/**/*.tsx')
.pipe(babel({
presets: [
[
"@babel/env",
{
targets: {
esmodules: true,
node: true
},
modules: "auto"
}
],
[
"@babel/preset-react" // react 插件
],
"@babel/preset-typescript"
]
}))
.pipe(gulp.dest(distDir));
}
function buildHtml() {
const jsxBabelOptions = {
presets: [
[
"@babel/env",
{
targets: {
esmodules: true,
node: true
},
modules: "auto"
}
],
[
"@babel/preset-react"
]
]
};
return gulp.src('src/**/*.html')
.pipe(cheerio(($, __) => {
$('script').each((__, element) => {
if (element.attribs && element.attribs.type === 'text/jsx') {
// 编译text/jsx节点
element.children.forEach(childer => {
if (childer.data) {
const codeTransed = babelCore.transform(childer.data, jsxBabelOptions).code;
childer.data = os.EOL + codeTransed + os.EOL;
}
})
element.attribs.type = 'text/javascript';
}
})
}))
.pipe(gulp.dest(distDir));
}
function buildAssets() {
return gulp.src('src/assets/**/*').pipe(gulp.dest(path.join(distDir, 'assets')));
}
function buildCss() {
return gulp.src('src/**/*.css')
.pipe(gulp.dest(distDir));
}
function buildLess() {
return gulp.src('src/**/*.less')
.pipe(less()).pipe(gulp.dest(distDir));
}
function cleanDist(cb) {
if (fs.existsSync(distDir)){
// fs模块的unlink老是会报权限问题
shell.rm('-rf', path.join(__dirname, 'dist'));
}
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir);
}
cb();
}
// 编译流程
exports.build = gulp.series([cleanDist, buildTsx, buildTs, buildHtml, buildCss, buildLess, buildAssets]);
运行
最终在package.json
中加入运行脚本并指定入口文件问dist/main.js
,如下:
{
"name": "ElectronIntagration",
"version": "1.0.0",
"main": "dist/main.js", // 运行入口文件
"author": "tensoar",
"license": "MIT",
"scripts": {
"build": "npx gulp build", // 编译
"start": "npx gulp build && npx electron ." // 运行
}
}
执行运行命令,即可启动程序:
yarn start