Electron+React.jsで作るチャットアプリ
Electron + React.jsで作るチャットアプリケーション
自分はElectronの初心者です。 Electronで本を購入しチャットアプリを写経して自分の中で理解を深めるためにここに残しておきます。
- 作成物
チャットアプリケーション
- 購入した本
- 感想
Electronについて概要からしっかり学べた
## Electron本体をインストール
- 作業用のディレクトリを作成
mkdir electron_chat
- 作業用のディレクトリを移動
cd electron_chat
- プロジェクト作成
npm init -y
(npmが使えなかった場合はインストールしてくる)
- Electronをインストール
npm install electron@1.6.1 --save-dev
- photonKitのインストール
photonKitとはmacOSネイティブアプリケーションのようなUIを簡単に実装できるFrameworkです。
npm install connors/photon --save-dev
- Reactのインストール
npm instal react@15.4.2 react-dom@15.4.2 react-router@3.0.0 --save
- babel関連モジュールのインストール
npm install babel-cli@6.18.0 babel-preset-es2015@6.18.0 babel-preset-react@6.16.0 --save-dev
実装
- index.js
import { app } from "electron"; import createWindow from "./createWindow"; import setAppMenu from "./setAppMenu" app.on("ready",() => { //アプリが起動したとき // 上でimportしたcreateWindowクラスのcreateWindow()メソッドを呼ぶ createWindow(); }); app.on("window-all-closed", () => { //mac以外の場合はウインドウを閉じた時にアプリを終了する if (process.platform !== "darwin") { app.quit(); } }); app.on("activate", (_e, hasVisibleWindows) => { if (!hasVisibleWindows) { createWindow(); } });
createWindow.js
import { BrowserWindow } from 'electron'; let win; function createWindow() { win = new BrowserWindow(); //BrowserWindowクラスを使ってHTMLファイルを読み込み画面に表示 win.loadURL(`file://${__dirname}/../../index.html`); win.on("close", () => { win = null; }); } export default createWindow;
- index.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>Electron Chat</title> <link rel="stylesheet" href="node_modules/photon/dist/css/photon.css"/> </head> <body> <div class="window"> <div id="app" class="window-content"></div> </div> <script>require("./.tmp/renderer/app.js")</script> </body> </html>
- app.jsx
Railsでいうrouteみたいなファイル。この中にFirebaseの設定も書き込む
import React from "react"; import {render} from "react-dom"; import { Router, Route, hashHistory } from "react-router"; import Login from "./Login"; import Signup from "./Signup"; import Rooms from "./Rooms"; import Room from "./Room"; import firebase from "firebase/firebase-browser" // Routingの定義 const appRouting = ( <Router history={hashHistory}> //親のpathから繋げていく <Route path="/"> //例えば "/login"を設定するとLoginクラスが呼ばれる <Route path="login" component={Login} /> <Route path="signup" component={Signup} /> <Route path="rooms" component={Rooms} > <Route path=":roomId" component={Room} /> </Route> </Route> </Router> ); //Routingの初期化 if (!location.hash.length) { location.hash = "#/login"; } // Firebaseの初期化 //ここはFirebaseからこぴぺ const config = { apiKey: "*******************", authDomain: "*******************", databaseURL: "*******************", projectId: "*******************", storageBucket: "", messagingSenderId: "*******************" }; firebase.initializeApp(config); // Applicationの描画 render(appRouting, document.getElementById("app"));
- .babellc
Reactをjavascriptに変換するためのファイル
{ "preset": ["es2015,"react] }
- Login.js
ログイン画面の実装
import React from "react"; import { Link, hashHistory } from "react-router"; import Errors from "./Errors"; import firebase from "firebase/firebase-browser"; const FORM_STYLE = { margin:"0 auto", padding:30 }; const SIGNUP_LINK_STYLE = { display:"inline-block", marginLeft: 10 }; export default class Login extends React.Component { constructor(props) { super(props); //props:親から渡される情報 //state:自Componentの状態 this.state = { email: localStorage.userEmail || "", password: localStorage.userPassword || "", errors:[], }; //それぞれのメソッドをbindする //bindすることでフロントの状態変化を検知できる this.handleOnChangeEmail = this.handleOnChangeEmail.bind(this); this.handleOnChangePassword = this.handleOnChangePassword.bind(this); this.handleOnSubmit = this.handleOnSubmit.bind(this); } // 下のinputの状態が変化したら呼ばれる handleOnChangeEmail(e){ this.setState({ email: e.target.value}); } // 下のinputの状態が変化したら呼ばれる handleOnChangePassword(e){ this.setState({ password: e.target.value }); } //ログイン処理 handleOnSubmit(e){ // 二つのinputタグの状態を取得する const { email, password } = this.state; const errors = []; let isValid = true; e.preventDefault(); if (!email.length) { //emailに何もデータが入っていない場合 isValid = false; errors.push("Email cannot blank."); } if (!password.length) { //passwordに何もデータが入っていない場合 isValid = false; errors.push("Password cannot be blank."); } if (!isValid) { //必須入力チェックに該当した場合はエラーを表示する this.setState({ errors }); return; } //Firebaseのログイン処理 //この辺はリファレンスを見る firebase.auth().signInWithEmailAndPassword(email,password).then(() => { //次回ログインし簡略化のため、localStorageに値を保存 localStorage.userEmail = email; localStorage.userPassword = password; //チャットルーム一覧画面へ遷移 hashHistory.push("/rooms"); }).catch(() => { // Firebaseでログインエラーになった場合 this.setState({errors: ["Incorrect email or password."] }) }); } //フロントのレンダリング render(){ return ( <form style={FORM_STYLE} onSubmit={this.handleOnSubmit}> <Errors errorMessages={this.state.errors} /> <div className="form-group"> <label>Email address</label> <input type="email" className="form-control" placeholder="email" onChange={this.handleOnChangeEmail} value={this.state.email} /> </div> <div className="form-group"> <label>Password</label> <input type="password" className="form-control" placeholder="password" onChange={this.handleOnChangePassword} value={this.state.password} /> </div> <div className="form-group"> <button className="btn btn-large btn-default">Login</button> <div style={SIGNUP_LINK_STYLE}> <Link to="/signup">create new account</Link> </div> </div> </form> ); } }
- signup.js
サインアップページ
import React from "react"; import { Link, hashHistory } from "react-router"; import Errors from "./Errors"; import firebase from "firebase/firebase-browser"; const SIGNUP_FORM_STYLE = { margin: "0 auto", padding: 30 }; const CANCEL_BUTTON_STYLE = { marginLeft: 10 }; export default class Signup extends React.Component{ constructor(props){ super(props); //状態の初期化 this.state = { email:"", password:"", name:"", photoURL:"", errors:[] }; //各種bindする this.handleOnChangeEmail = this.handleOnChangeEmail.bind(this); this.handleOnChangePassword = this.handleOnChangePassword.bind(this); this.handleOnChangeName = this.handleOnChangeName.bind(this); this.handleOnChangePhotoURL = this.handleOnChangePhotoURL.bind(this); this.handleOnChangeSubmit = this.handleOnChangeSubmit.bind(this); } handleOnChangeEmail(e){ this.setState({ email: e.target.value}); } handleOnChangePassword(e){ this.setState({ password:e.target.value }); } handleOnChangeName(e){ this.setState({ name: e.target.value}); } handleOnChangePhotoURL(e){ this.setState({photoURL: e.target.value }); } handleOnChangeSubmit(e){ const {email, password, name,photoURL } = this.state; const errors = []; let isValid = true; e.preventDefault(); if (!email.length) { isValid = false; errors.push("Email address cannt be blank."); } if (!password.length) { isValid = false; errors.push("Password cannot be blank"); } if (!name.length) { isValid = false; errors.push("Name cannot be blank."); } if (!isValid) { this.setState({errors}); return; } // Firebaseの新規アカウント作成処理 firebase.auth().createUserWithEmailAndPassword(email,password).then(newUser => { //ユーザー情報を更新する return newUser.updateProfile({ displayName:name,photoURL }); }).then(() => { //チャットルーム一覧画面へ遷移 hashHistory.push("/rooms"); }).catch(err => { // Firebaseでエラーになった場合 this.setState({errors: [err.message] }) }); } render(){ return ( <form style={SIGNUP_FORM_STYLE} onSubmit={this.handleOnChangeSubmit}> <Errors errorMessages={this.state.errors} /> <div className="form-group"> <label>Email address *</label> <input type="email" className="form-control" placeholder="email" value={this.state.email} onChange={this.handleOnChangeEmail} /> </div> <div className="form-group"> <label>Password *</label> <input type="password" className="form-control" placeholder="password" value={this.state.password} onChange={this.handleOnChangePassword} /> </div> <div className="form-group"> <label>User name *</label> <input type="text" className="form-control" placeholder="user_name" value={this.state.name} onChange={this.handleOnChangeName} /> </div> <div className="form-group"> <label>Photo URL</label> <input type="text" className="form-control" placeholder="photo url" value={this.state.photoURL} onChange={this.handleOnChangePhotoURL} /> </div> <div className="form-group"> <button className="btn btn-large btn-primary">Create new account</button> <Link to="/login"> <button type="button" style={CANCEL_BUTTON_STYLE} className="btn btn-large btn-default" > cencel </button> </Link> </div> </form> ); } }
- Rooms.jsx
左ペインにRoom一覧を表示し、右ペインにはチャットルームを表示する
import React from "react"; import { hashHistory } from "react-router"; import RoomItem from "./RoomItem"; import firebase from "firebase/firebase-browser"; const ICON_CHAT_STYLE = { fontSize: 120, color:"#DDD" }; const FORM_STYLE = { display: "flex" }; const BUTTON_STYLE = { marginLeft: 10 }; export default class Rooms extends React.Component { constructor(props){ super(props); this.state = { roomName: "", rooms:[] }; // Firebaseデータベースを初期化 this.db = firebase.database(); this.handleOnChangeRoomName = this.handleOnChangeRoomName.bind(this); this.handleOnSubmit = this.handleOnSubmit.bind(this); } componentDidMount(){ //コンポーメントの初期化時にチャットルーム一覧を取得する this.fetchRooms(); } handleOnChangeRoomName(e){ this.setState({ roomName: e.target.value }); } handleOnSubmit(e){ const { roomName } = this.state; e.preventDefault(); if (!roomName.length) { return; } //Firebaseデータベースに新規チャットルームのデータ作成 const newRoomRef = this.db.ref("/chatrooms").push(); const newRoom = { description: roomName }; //作成したチャットルームのdescriptionを更新する newRoomRef.update(newRoom).then(() => { //状態を再初期化する this.setState({ roomName:"" }) //チャットルーム一覧を再取得 return this.fetchRooms().then(() => { //右ペインを作成した詳細画面に遷移させる hashHistory.push(`/rooms/${newRoomRef.key}`); }); }); } //チャットルーム一覧の取得 fetchRooms(){ //Firebaseデータベースからチャットルームを20件取得 return this.db.ref("/chatrooms").limitToLast(20).once("value").then(snapshot => { const rooms = []; snapshot.forEach(item => { //データベースから取得したデータオブジェクトとして取り出す rooms.push(Object.assign({key:item.key}, item.val())); }); //取得したオブジェクトの配列をコンポーメントのstateにセット this.setState({rooms}); }); } //左ペイン(チャットルーム一覧)の描写処理 renderRoomList(){ const { roomId } = this.props.params; const { rooms, roomName } = this.state; return ( <div className="list-group"> //ルーム一覧をfetchRoomsから取得したデータ分だけ表示する //selectしたらRoomItemにroomIdを設定する // RoomItemタグを使っているといことはRoomItemクラスを呼んでいる {rooms.map(r => <RoomItem room={r} key={r.key} selected={r.key === roomId} /> )} //新しいチャットルームを作成するためのinputエリア <div className="list-group-header"> <form style={FORM_STYLE} onSubmit={this.handleOnSubmit}> <input type="text" className="form-control" placeholder="New room" onChange={this.handleOnChangeRoomName} value={roomName} /> <button className="btn btn-default" style={BUTTON_STYLE}> <span className="icon icon-plus" /> </button> </form> </div> </div> ); } //右ペイン(チャットルーム詳細)の描写処理 renderRoom(){ if (this.props.children) { // propsにデータが設定されている = Roomが設定されている return this.props.children; } else { return ( <div className="text-center"> <div style={ICON_CHAT_STYLE}> <span className="icon icon-chat" /> </div> <p> Join a chat room from the sidebar or create your chat room. </p> </div> ); } } render(){ return ( <div className="pane-group"> //左ペイン <div className="pane-sm sidebar">{this.renderRoomList()}</div> //右ペイン <div className="pane">{this.renderRoom()}</div> </div> ); } }
- RoomItem.jsx
Room一覧ののひとつひとつのアイテム
import React from "react" import { Link } from "react-router" const LINK_STYLE = { color:"inherit", textDecoration:"none" }; export default function RoomItem(props) { //propsからアイテムひとつひとつを設定する const { selected } = props; const { description, key } = props.room; return ( <div className={selected ? "list-group-item selected":'list-group-item'}> //`/rooms/${key}`の設定でroom.jsxが呼ばれて設定される <Link to={`/rooms/${key}`} style={LINK_STYLE}> <div className="media-body"> <strong>{description}</strong> </div> </Link> </div> ); }
- Room.jsx
選択されているチャットルームでチャット処理を実装する
import React from "react"; import Message from "./Message"; import NewMessage from "./NewMessage"; import firebase from "firebase/firebase-browser"; const ROOM_STYLE = { padding: "10px 30px" }; export default class Room extends React.Component { constructor(props){ super(props); this.state = { description: "", messages:[], }; this.db = firebase.database(); this.handleOnMessagePost = this.handleOnMessagePost.bind(this); } componentDidMount(){ const { roomId } = this.props.params; //コンポーメントの初期化時にチャットルームの詳細情報を取得する this.fetchRoom(roomId); } componentWillReceiveProps(nextProps){ const { roomId } = nextProps.params; if (roomId === this.props.params.roomId) { //チャットルームのIDいん変更がなければなにもしない return; } if (this.stream) { //メッセージの監視解除 this.stream.off(); } // stateの再初期化 this.setState({ messages:[] }); //チャットルーム詳細の再取得 this.fetchRoom(roomId); } componentDidUpdate(){ setTimeout(() => { //画面下端へスクロール this.room.parentNode.scrollTop = this.room.parentNode.scrollHight; }, 0); } componentWillUnmount(){ if (this.stream) { //メッセージ監視を解除 this.stream.off(); } } //メッセージ投稿処理 handleOnMessagePost(message){ const newItemRef = this.fbChatRoomRef.child("messages").push(); ///Firebaseにログインしているユーザーを投稿ユーザーとして利用 this.user = this.user || firebase.auth().currentUser; return newItemRef.update({ writtenBy:{ uid:this.user.uid, displayName: this.user.displayName, photoURL:this.user.photoURL, }, time:Date.now(), text:message, }); } fetchRoom(roomId){ //Firebaseデータベースからチャットルーム詳細データの参照を取得 this.fbChatRoomRef = this.db.ref("/chatrooms/" + roomId); this.fbChatRoomRef.once("value").then(snapshot => { const { description } = snapshot.val(); this.setState({ description }); window.document.title = description; }); this.stream = this.fbChatRoomRef.child("messages").limitToLast(10); //チャットルームのメッサージ追加を監視 this.stream.on("child_added",item => { const { messages } = this.state || []; // 追加されたメッセージをstateにセット messages.push(Object.assign({key:item.key},item.val())); this.setState({ messages }); }); } render(){ const { messages } = this.state; return ( <div style={ROOM_STYLE} ref={room => this.room = room} > <div className="list-group"> //fetchRoomで取得したデータ分だけ画面に表示 {messages.map(m => <Message key={m.key} message={m} />)} </div> <NewMessage onMessagePost={this.handleOnMessagePost} /> </div> ); } }
- Message.jsx
1メッセージのオブジェクト
import React from "react"; import Avatar from "./Avatar"; const MEDIA_BODY_STYLE = { color: "#888", fontSize:".9em" }; const TIME_STYLE = { marginLeft: 5 }; const TEXT_STYLE = { whiteSpace:"normal", wordBreak:"break-word" }; export default function Message(props){ const { text, time, writtenBy } = props.message; const localeString = new Date(time).toLocaleString(); return ( <div className="list-group-item"> <div className="media-object pull-left"> <Avatar user={writtenBy} /> </div> <div className="media-body"> <div style={MEDIA_BODY_STYLE}> <span>{writtenBy.displayName}</span> <span style={TIME_STYLE}>{localeString}</span> </div> <p style={TEXT_STYLE}> {text} </p> </div> </div> ); }
- NewMessage.jsx
投稿するinputタグ
import React from "react" const FORM_STYLE = { display: "flex" }; const BUTTON_STYLE = { marginLeft: 10 }; export default class NewMessage extends React.Component{ constructor(props){ super(props); this.state = {message: ""}; this.handleOnChange = this.handleOnChange.bind(this); this.handleOnSubmit = this.handleOnSubmit.bind(this); } handleOnChange(e){ this.setState({message:e.target.value}); } handleOnSubmit(e){ const { onMessagePost } = this.props; if (!onMessagePost || !this.state.message.length) { return; } onMessagePost(this.state.message); this.state({ message: ""}); e.preventDefault(); } render(){ return( <form style={FORM_STYLE} onSubmit={this.handleOnSubmit}> <input type="text" className="form-control" onChange={this.handleOnChange} value={this.state.message} /> <button className="btn btn-large btn-primary" style={BUTTON_STYLE}>POST</button> </form> ); } }
- Avatar.jsx
import React from "react" const AVATAR_STYLE = { width:32, textAlign:"center", fontSize:24 }; export default function Avatar(props){ const { photoURL } = props.user; if(photoURL){ // photoURLURLが設定されている場合img要素を表示 return <img className="img-rounded" src={photoURL} style={AVATAR_STYLE} />; } else { //photoURLが設定されていない場合代替えとしてiconを表示 return ( <div style={AVATAR_STYLE}> <span className="icon icon-user" /> </div> ); } }
実行
- Reactをトランスコンパイル
./node_modules/.bin/babel --out-dir tmp src
- 実行
./node_modules/.bin/electron .
すると、実行できる
最後に
ただのメモ書きになってしましたが、 この本を勉強してかなり理解が深まったのでおすすめです。