Electron + React 实战:炫酷的代码编辑器

Electron是类似node-webkit的一个工具,它们给前端开发者开辟了一个新的天地,借助它,我们只需要写写html,js,css,就可以轻松开发跨平台的桌面app,这难道不是前端们一直以来的梦想么。

本文结合时下热门的React来做一个简单例子。

项目搭建

我们的目录结构很简单

niceditor  
 |
 |-public/
 |    |-app/
 |    \-images/
 |
 |-main.js
 |-index.html
 |-gulpfile.js
 \-package.json

初始化package.json文件

{
  "name": "niceditor",
  "version": "1.0.0",
  "description": "a nice editor",
  "main": "main.js",
  "scripts": {
    "start": "tnpm install && gulp watch"
  },
  "repository": {},
  "license": "MIT",
  "devDependencies": {
    "babel-core": "^5.4.7",
    "electron-prebuilt": "^0.33.7",
    "gulp": "^3.9.0",
    "gulp-babel": "^5.2.1",
    "gulp-less": "^3.0.3",
    "gulp-livereload": "^3.8.1",
    "gulp-run": "^1.6.11",
    "react": "^0.14",
    "react-dom": "^0.14.0",
    "codemirror": "^5.8.0"
  }
}

gulpfile.js参考

var path = require("path");  
var gulp = require('gulp');  
var livereload = require('gulp-livereload');  
var run = require('gulp-run');  
var less = require('gulp-less');  
var babel = require('gulp-babel');

var lessOptions = {  
    relativeUrls: true
};
var babelOptions = {  
    whitelist: ['react', 'strict', 'es6.destructuring']
};

gulp.task("default", ["server"]);  
gulp.task("watch", ["server"]);  
gulp.task("start", ["server"]);  
gulp.task("server", ["babel_demo", "less_demo"], function (callback) {  
    livereload.listen();

    run('electron .').exec();

    gulp.watch(['public/**/*.jsx', 'index.jsx'], function (event) {
        if (event.type === 'deleted') return;

        gulp.src(event.path)
            .pipe(babel(babelOptions))
            .pipe(gulp.dest(path.dirname(event.path)));
    });

    gulp.watch(['public/**/*.js', './*.html']).on('change', livereload.reload);

    gulp.watch(['public/**/*.less'], ['reload_by_css']);

    callback();
});

gulp.task('less_demo', function (callback) {  
    gulp.src(['public/app/*.less'])
        .pipe(less(lessOptions))
        .pipe(gulp.dest('public/app'));
    callback();
});

gulp.task('babel_demo', function (callback) {  
    gulp.src('public/**/*.jsx').pipe(babel(babelOptions)).pipe(gulp.dest('public'));
    gulp.src('index.jsx').pipe(babel(babelOptions)).pipe(gulp.dest('.'));
    callback();
});

gulp.task('reload_by_css', function (callback) {  
    gulp.src(['public/app/*.less'])
        .pipe(less(lessOptions))
        .pipe(gulp.dest('public/app'))
        .pipe(livereload());
    callback();
});

项目文件准备好了,现在可以从一个简单的命令启动

$ tnpm start

一大堆安装过后,发现只有一个图标,别急,入口文件还没好呢。。。

开始

我们在package.json定义了main字段,这个字段被electron识别为入口文件,这个入口文件main.js大致如下。

"use strict";

const app = require('app');  
const BrowserWindow = require('browser-window');

let mainWindow = null;

app.on('window-all-closed', () => {  
    app.quit();
});

app.on('ready', () => {  
    mainWindow = new BrowserWindow({
        width: 800,
        height: 500,
        title: "niceditor"
    });

    mainWindow.on('closed', () => {
        mainWindow = null;
    });
});

运行npm start,就可以看到一个窗口。

窗口里现在什么都没有,下面开始加内容。

// ...

app.on('ready', () => {  
    mainWindow = new BrowserWindow({
        width: 1080,
        height: 720,
        "title-bar-style": "hidden"
    });

    // 加载本地html文件
    mainWindow.loadUrl('file://' + __dirname + '/index.html');

    // ...
});

// ...

本文中// ...代表省略上文已有的部分

index.html内容如下。

<!DOCTYPE html>  
<html>  
<head>  
    <meta charset="utf-8" />
    <title>niceditor</title>
    <link rel="stylesheet" href="public/app/index.css" />
</head>  
<body>  
    <div id="body"></div>
    <script>require('./public/app/index');</script>
    <!--用于livereload-->
    <script src="http://127.0.0.1:35729/livereload.js?ext=Chrome&extver=2.1.0"></script>
</body>  
</html>  

我们不写hello world,直接出一个炫酷的窗口,编写样式文件public/app/index.less

body,html,#body {  
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    top: 0;
    box-sizing: border-box;
    padding: 0;margin: 0;
    overflow: hidden;
    -webkit-user-select: none;
}

html {  
    background: #717171 url(../images/moon.jpg) no-repeat top center;
    background-size: cover;
}

less文件会被编译为对于路径下的css文件,同样我们编写脚本文件public/app/index.jsx

const React = require('react');  
const ReactDOM = require('react-dom');

ReactDOM.render((  
    <div/>
), document.getElementById('body'));

运行npm start,看。。。

背景图片来自/Library/Desktop Pictures/Earth and Moon.jpg

布局

离编辑器还是有差距,我们加快脚步,在index.jsx文件中进行组织。

const React = require('react');  
const ReactDOM = require('react-dom');  
const Editor = require('../editor');

ReactDOM.render((  
    <Editor />
), document.getElementById('body'));

public/editor.jsx文件

const React = require('react');  
const ReactDOM = require('react-dom');  
const CodeBox = require('./codebox');

class Editor extends React.Component {  
    render() {
        return <div className="editor">
            <div className="actions">
                <button className="cyan openbutton">打开</button>
                <button className="blue">保存</button>
            </div>
            <CodeBox />
        </div>;
    }
}

module.exports = Editor;  

public/codebox.jsx文件

const React = require('react');

class CodeBox extends React.Component {  
    render() {
        return <div className="codebox" />;
    }
}

module.exports = CodeBox;  

index.less文件追加样式。

// ...

button {  
    margin: 0 5px;
    border: none;
    border-radius: 2px;
    padding: 4px 23px;
    color: white;
    font-size: 12px;
    outline: none;
    cursor: pointer;
    line-height: normal;

    &.blue {
        background: #3f9af9;

        &:hover {
            background: #2786e8
        }
    }
    &.cyan {
        background: #5db4cf;

        &:hover {
            background: #429dba
        }
    }

    &.openbutton {
        position: relative;
    }
}

.actions {
    position: absolute;
    top:20px;
    right: 20px;
    line-height: 37px;
    height: 37px;
    padding: 0 5px;
    text-align: center;
}

.codebox {
    position: absolute;
    top:57px;
    bottom: 10px;
    left:50%;
    width: 70%;
    margin: 0 auto;
    transform: translateX(-50%);
    border: 1px solid white;
    border-radius: 5px;
    overflow: hidden;
    background-color: rgba(39, 40, 34, .8);
}

在看一下效果。。。

布局完成了,接下来完成逻辑段。

打开文件

一般在按钮里面埋入一个input[type=file],就可以调出选择文件窗口,input发生onchange事件时从事件参数中获得文件e.target.files[0],改写editor.jsx如下。

// ...
class Editor extends React.Component {

    constructor(props) {
        super(props);

        this.state = {
            file: null
        };
    }

    onFile(e) {
        this.setState({file:e.target.files[0].path});
    }

    render() {
        return <div className="editor">
            <div className="actions">
                <button className="cyan openbutton">
                    <label><input type="file" accept=".js" onChange={this.onFile.bind(this)} /></label>
                    打开
                </button>
                <button className="blue">保存</button>
            </div>
            <CodeBox file={this.state.file} />
        </div>;
    }
}

修改index.less文件中.openbutton段。

&.openbutton {
   position: relative;
   label {
       position:absolute;
       top:0;left:0;
       width:100%;height:100%;
       display:inline-block;
       cursor:pointer;
       background:#fff;
       overflow:hidden;
       opacity:0;
       input {
           position:absolute;
           clip:rect(1px 1px 1px 1px);
       }
   }
}

使用codemirror编辑文件

打开文件只获得到了文件路径,接下来使用fs读取文件内容,传给CodeMirror编辑器,改写codebox.jsx如下。

const React = require('react');  
const ReactDom = require('react-dom');  
const fs = require('fs');  
const CodeMirror = require('codemirror');

// 加载javascript语法解析器
require('codemirror/mode/javascript/javascript');  
// 加载一些插件
require('codemirror/addon/selection/active-line');  
require('codemirror/addon/edit/matchbrackets');

class CodeBox extends React.Component {  
    componentDidMount() {
        const code = ReactDom.findDOMNode(this);

        this.doc = CodeMirror(code, {
            value: "// open a javascript file...",
            lineNumbers: true,
            styleActiveLine: true,
            matchBrackets: true,
            mode:  "javascript",
            theme: 'monokai'
        });
    }

    shouldComponentUpdate(props) {
        if (props.file !== this.file) {
            this.file = props.file;
            this.loadFile();
        }

        return false;
    }

    loadFile() {
        this.doc.setValue(fs.readFileSync(this.file, {encoding:'utf-8'}));
    }

    render() {
        return <div className="codebox" />;
    }
}

module.exports = CodeBox;  

index.less文件头部导入codemirror相关样式,并在.codebox段美化一下样式,如下。

@import "../../node_modules/codemirror/lib/codemirror.css";
@import "../../node_modules/codemirror/theme/monokai.css";

// ...

.codebox {
    position: absolute;
    top:57px;
    bottom: 10px;
    left:50%;
    width: 70%;
    margin: 0 auto;
    transform: translateX(-50%);
    border: 1px solid white;
    border-radius: 5px;
    overflow: hidden;

    // background-color: rgba(39, 40, 34, .8);

    .CodeMirror {
        height: 100%;
    }
    .cm-s-monokai.CodeMirror {
        background-color: rgba(39, 40, 34, .8);
    }
    .cm-s-monokai .CodeMirror-gutters {
        background-color: rgba(39, 40, 34, .7);
    }
}

打开一个文件看一下效果。。。

嗯。。。还是挺好看的,就差保存了。

保存文件

修改的文件,保存时用fs写入文件,我们给CodeBox添加一个save方法。

// ...

class CodeBox extends React.Component {  
    // ... 

    save() {
        if (!this.file) {
            return;
        }
        fs.writeFileSync(this.file, this.doc.getValue(), 'utf8');

        alert("保存成功");
    }

    // ...
}

// ...

保存按钮点击时调用此save方法,改写Editor.render函数如下。

// ....
class Editor extends React.Component {  
    // ...

    render() {
        return <div className="editor">
            <div className="actions">
                <button className="cyan openbutton">
                    <label><input type="file" accept=".js" onChange={this.onFile.bind(this)} /></label>
                    打开
                </button>
                <button className="blue" onClick={() => {
                    this.refs.codebox.save()
                }}>保存</button>
            </div>
            <CodeBox file={this.state.file} ref="codebox" />
        </div>;
    }
}
// ...

保存功能OK了,这样我们的demo就完成了。

打包

项目开发完了,我们要制作niceditor.app、niceeditor.exe等封装包,分发到网络供用户下载使用,这个话题待续。。。你可以直接参见这里http://electron.atom.io/docs/v0.33.0/tutorial/application-distribution/

结语

完整代码在这里https://github.com/yanbingbing/niceditor

这个例子能这么简单完成,完全是依赖于强大的codemirror,electron,react等类库。