electron提供了一个简易、开箱即用的桌面端跨平台桌面GUI开发方案,typescript提供了静态类型检查,而react可以简化和丰富界面交互,三者结合,可大大提示开发体验。在一些例子中,reactelectron的结合一般是分离的,即页面布局交互作为单独的react工程,作为一个http服务启动,然后在electron的入口文件中引入首页的URL,这种方式要额外监听一个端口,且不够集成。因此,下面实现一种集成方案,直接在electron中像引入普通html文件一样引入react文件,做到无缝集成。

初始化

typescript不必说经过tsc编译即可运行,但reacttsx文件无法直接编译,其编译过程需要用到插件且与ts的编译有所差异,但最终目的都是将其编译为普通js文件,因此,我们考虑通过引入自动化流程构建工具,手动编写编译规则和流程,完成tstsc以及样式文件(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>&emsp;
            <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

其中第一个依赖是添加gulpbabel的支持,第二个依赖是环境配置,会自动配置一些预编译环境,第三个依赖是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

编译tsxts的基础上加入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