0. 起因
老板要搞个Log分析工具,数据存储选用的是Elasticsearch,起初想法是做个Kibana的插件,后来觉得依靠Kibana太庞大,而且后期想要把代码直接部署在GitHub page上,因此打算做成个独立的工具。最终选用了Node.js+React,主要原因是相中了一个React UI库EUI(Elastic Stack推出的一个开源UI库,风格与Kibana一样),毕竟,不是谁都能写出漂亮的UI,强如Linux之父Linus都表示“如果我被困在一个与世隔绝的岛上,逃离这座岛的唯一办法是写出漂亮的UI,那我估计就老死在岛上了”。
虽然最终由于内网权限问题没部署上GitHub page,但基本流程都跑通了。既然代码都写了,顺路写个总结吧。
1. 工程搭建
首先,我们需要安装Node.js,到Node.js官网随便下一个安装包安装,或者下载压缩包解压缩后手动设置环境变量使用。我比较喜欢直接使用压缩包,因为这样可以随意在多个版本间切换而且不用额外的工具辅助。例如在Ubuntu下下载压缩包解压缩并通过命令export PATH=$NODEJS_ROOT/bin:$PATH
即完成了安装。安装完成后可以通过以下命令查看是否安装成功:
node --version
npm --version
安装完成后,按照以下结构建立一个目录:
my-app/
package.json
public/
index.html
src/
index.js
其中my-app
可以改成任意你喜欢的名字,剩余的部分名字必须与例子给出的一致,这是工程可以构建的前提。
然后,打开package.json
,在其中填入以下内容并保存:
{
"name": "eui-demo",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^7.2.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-scripts": "3.4.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
上面的内容中,dependencies
和scripts
这两个对象必须有,其他的可选择性添加,即最小要求如下:
{
"dependencies": {
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-scripts": "3.4.3"
},
"scripts": {
"start": "react-scripts start",
}
}
package.json
文件准备好后执行以下命令:
npm install
npm start
执行完上面的命令,整个工程已经构建完毕,在浏览器中输入http://localhost:3000/
即可访问你刚构建起来的应用,虽然目前这个应用什么也没做也没有显示任何内容。
当然,其实还有另一个更加简单的方法来构建应用,Node.js安装完成后只需要在终端输入一条命令即可 ,同样,my-app
可以换成任意名字:
npx create-react-app my-app/
等待命令执行完毕即可。
2. Hello World?
如果工程是我们自己手动一步一步搭建起来的,通过浏览器访问http://localhost:3000/
是什么都不会显示的。下一步,我们就需要让它显示点什么。编程嘛,就从Hello World开始吧。
首先,我们打开my-app/public/index.html
,在里面输入一下信息并保存:
上面的超文本标记代码很简单,如果用浏览器打开的话还是什么也看不到。它只是为后续的React UI代码提供了一个挂载点 —— id为content
的一个div
,在我们的小例子中,对HTML的编辑就算完了,身下的就全部交给JS代码了。
接着,我们打开my-app/src/index.js
,输入一下代码:
import React from 'react';
import ReactDOM from 'react-dom';
function HelloWorld(props) {
return (Hello World!
)
}
ReactDOM.render(
,
document.getElementById('content')
);
编辑完my-app/src/index.js
并保存之后,我们在my-app
目录中执行npm start
,就可以在浏览器中看到如图1结果:
在上面的代码中,我们做了三件事:
- 第一第二行代码分别从
react
以及react-dom
这两个模块中导入了React
以及ReactDOM
这两个类。值得注意的是,虽然我们没有直接看到使用导入的React
,但是这个导入语句是必须的,否则编译就会报错! - 接下来,定义了一个名为
HelloWorld
的函数,这个函数在React中称为函数组件*(Function Components),它与类组件(Class Components)一起组成了React 渲染UI的核心。这个函数只有一个参数props
,这个props
实际上是一个字典,可以通过它传递任意参数给函数组件;函数返回一个描述如何显示UI的React元素,虽然看着像超文本标记语言(HTML),但是它却不是。它的名字叫做JSX(JavaScript eXtension),它是JavaScript语法的扩展。 - 第三步,就是将我们定义的函数组件通过
ReactDOM.render()
函数渲染出来。ReactDOM.render()
需要一个挂载节点,在我们的例子中的挂载节点是前面提到的id为content
的一个div
,通过ReactDOM.render()
渲染的界面都托管在React DOM中,由React DOM负责管理以及更新。
3. 实践
有了Hello World的铺垫,我们现在可以正式搭建一个简单点的应用了。我们选用的UI框架是Elastic UI,单然如果你有自己喜欢的其他框架也是可以的。
假设我们要搭建一个Markdown编辑器。我们确定它的结构如图2,我们需要用EUI实现我们的目标:
3.1. 搭架子
我们在EUI中找到一个名叫Page
的布局空间,其布局如图3,正好符合我们的期望:
同样,我们分别找到导航组件(tree-view)、标签组件(tabs)以及Markdown编辑框组件(markdown-editor),将它们搭积木一样组合起来,并做些调整就能得到如图4所示的界面:
为了方便起见,我们将Page组件、导航栏组件、标签栏组件、编辑器组件代码放到独立JS文件中,分别命名为page.js, file-nav.js, tabs.js, markdown-editor.js
,具体结构如下:
my-app/
package.json
public/
index.html
src/
file-nav.js
index.js
markdown-editor.js
page.js
tabs.js
他们的代码分别如下所示:
// file-nav.js
import React from 'react';
import { EuiIcon, EuiTreeView, EuiToken } from '@elastic/eui';
export default () => {
const showAlert = () => {
alert('You squashed a bug!');
};
const items = [
{
label: 'src',
id: 'src',
icon: ,
iconWhenExpanded: ,
isExpanded: true,
children: [
{
label: 'index.md',
id: 'item_a',
icon: ,
},
{
label: 'level2 folder',
id: 'item_b',
icon: ,
iconWhenExpanded: ,
children: [
{
label: 'monosodium_glutammate.md',
id: 'item_cloud',
icon: ,
},
{
label: "cobalt.md",
id: 'item_bug',
icon: ,
callback: showAlert,
},
],
},
{
label: 'xxxxx folder',
id: 'item_c',
icon: ,
iconWhenExpanded: ,
children: [
{
label: 'Another Cloud.md',
id: 'item_cloud2',
icon: ,
},
{
label:
'elastic_link.md',
id: 'item_bug2',
icon: ,
callback: showAlert,
},
],
},
],
},
{
label: 'othter',
id: 'src2',
icon: ,
iconWhenExpanded: ,
isExpanded: true,
},
];
return (
);
};
// markdown-editor.js
import React, { useCallback, useState } from 'react';
import {
EuiMarkdownEditor,
EuiSpacer,
EuiCodeBlock,
EuiButtonToggle,
} from '@elastic/eui';
const initialContent = `## Hello world!
Basic "github flavored" markdown will work as you'd expect.
The editor also ships with some built in plugins. For example it can handle checkboxes. Notice how they toggle state even in the preview mode.
- [ ] Checkboxes
- [x] Can be filled
- [ ] Or empty
`;
const dropHandlers = [
{
supportedFiles: ['.jpg', '.jpeg'],
accepts: itemType => itemType === 'image/jpeg',
getFormattingForItem: item => {
// fake an upload
return new Promise(resolve => {
setTimeout(() => {
const url = URL.createObjectURL(item);
resolve({
text: `![${item.name}](${url})`,
config: { block: true },
});
}, 1000);
});
},
},
];
export default () => {
const [value, setValue] = useState(initialContent);
const [messages, setMessages] = useState([]);
const [ast, setAst] = useState(null);
const [isAstShowing, setIsAstShowing] = useState(false);
const onParse = useCallback((err, { messages, ast }) => {
setMessages(err ? [err] : messages);
setAst(JSON.stringify(ast, null, 2));
}, []);
return (
<>
{isAstShowing && {ast} }
>
);
};
// page.js
import React from 'react';
import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiPageHeader,
EuiPageSideBar,
} from '@elastic/eui';
import FileNav from './file-nav';
import MarkdownEditor from './markdown-editor';
import Tabs from './tabs';
export default () => (
);
// tabs.js
import React, { useState, Fragment } from 'react';
import {
EuiIcon,
EuiTabs,
EuiTab,
EuiSpacer,
} from '@elastic/eui';
const tabs = [
{
id: 'cobalt',
name: (
cobalt.md
),
disabled: false,
},
{
id: 'dextrose',
name: (
dextrose.md
),
disabled: false,
},
{
id: 'hydrogen',
name: (
Hydrogen
),
disabled: false,
},
{
id: 'monosodium_glutammate',
name: (
monosodium_glutammate.md
),
disabled: false,
},
{
id: 'elastic_link',
name: (
elastic_link.md {alert('close me?')}}>
),
disabled: false,
},
];
export default () => {
const [selectedTabId, setSelectedTabId] = useState('cobalt');
const onSelectedTabChanged = id => {
setSelectedTabId(id);
};
const renderTabs = () => {
return tabs.map((tab, index) => (
onSelectedTabChanged(tab.id)}
isSelected={tab.id === selectedTabId}
disabled={tab.disabled}
key={index}>
{tab.name}
));
};
return (
{renderTabs()}
);
};
// index.js
import React, {useState} from 'react';
import ReactDOM from 'react-dom';
import "@elastic/eui/dist/eui_theme_light.css";
import Editor from './page';
ReactDOM.render(
,
document.getElementById('content')
);
好了,到这里,我们的架子已经搭起来了,我们接下来就需要为他们注入灵魂,让各个组件之间互动起来。例如我们希望点击不同的文件标签,编辑框显示的是不同的文件内容。
3.2. 关联UI
点击不同的文件标签让编辑框显示不同内容主要涉及的就是UI如何更新自己的状态或者UI如何通知别的UI更新其状态。在React中,UI被前面提到的类组件(Class Component)以及函数组件(Function Component)分为一个个独立、可复用的模块。类组件和函数组件的形式分别如下:
// Class components
class ClazzComponent extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
render() {
return Hello, {this.props.name}
;
}
}
// Function Components
function FuncComponent(props) {
return Hello, {props.name}
;
}
从显示效果上看,它们是一完全一样的。类组件相对复杂但是拥有更多的特性,例如类组件就有一个state
字典,通过setState
方法类组件可以更新state
的内容,一旦state
改变了,那么直接或者间接使用state
的React元素(React Elements)就会被更新。例如我们把上面的ClazzComponent
做下修改:
// Class components
export default class ClazzComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
what: 'World',
};
}
render() {
return this.setState({what: this.state.what + ' World'})}>Hello, {this.state.what}
;
}
}
当我们点击界面上的Hello, World
,它就会变成Hello, World World
,Hello, World World World
等。函数组件相比于类组件最大的区别就是很多类组件有的特性它没有,例如它就没有state
这个成员以及setState
方法,因为理论上它是一个函数(至于说在JavaScript中“万物皆对象”,方法其实也是个对象,那不是本文关注的重点)。但是,在React中通过一些钩子函数,就能让函数组件具有类组件的一些特性,例如“state”。
让stateless的方法函数变得state,可以通过useState
这个钩子函数。例如,我们把上面的示例也做下修改:
// Function Components
import React, {useState} from 'react';
function FuncComponent(props) {
const [name, setName] = useState('World');
return setName(name + ' World')}>Hello, {name}
;
}
经过修改,函数组价的的表现也和类组件一样了。首先,我们从React模块中导入useState
这个钩子,然后我们在函数组件中通过它获得了一个厨师长name
已经更新方法setName
,这里name
和setName
可以是任意的名字。
知道了如何更新组件的状态,接下来我们就能着手进行我们的Markdown编辑器的编码了。篇幅有限,我们搞得简单点。主要分以下三步:
- 首先,我们定义一个回调函数,将这个回调函数注册到
tabs.js
的函数组件中; - 然后,当
tabs
的标签有改变的时候,tabs
调用我们注册的回调函数,并将被选中的tab的id传给我们回调函数,这样我们就能知道当前那个标签被选中了; - 最后,在回调函数中,我们通过判断id知道用户希望显示的内容,通过
useState
导出的setContent
方法通知Markdown编辑器控件更改其显示的内容。
具体代码如下所示,我们只更改了page.js, markdown-editor.j, tabs.js
,其他代码保持不变:
// page.js
import React, {useState} from 'react';
import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiPageHeader,
EuiPageSideBar,
} from '@elastic/eui';
import FileNav from './file-nav';
import MarkdownEditor from './markdown-editor';
import Tabs from './tabs';
import tabs from './tabs';
const tab1Content = `## Hello world!
Basic "github flavored" markdown will work as you'd expect.
The editor also ships with some built in plugins. For example it can handle checkboxes. Notice how they toggle state even in the preview mode.
- [ ] Checkboxes
- [x] Can be filled
- [ ] Or empty
`;
const tab2Content = `## I am tab two, name , not Tattoo!
#### I am tab two, not Tattoo!
`;
const tab3Content = `
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### \`npm start\`
Runs the app in the development mode.
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.
You will also see any lint errors in the console.
`;
export default () => {
const [content, setContent] = useState('init content')
let tabSelected = (tabId) => {
if (tabId == 'cobalt')
setContent(tab1Content);
else if (tabId == 'dextrose')
setContent(tab2Content);
else if (tabId == 'hydrogen')
setContent(tab3Content);
}
return (
);
};
//markdown-editor.js
import React, { useCallback, useState } from 'react';
import {
EuiMarkdownEditor,
EuiSpacer,
EuiCodeBlock,
EuiButtonToggle,
} from '@elastic/eui';
const dropHandlers = [
{
supportedFiles: ['.jpg', '.jpeg'],
accepts: itemType => itemType === 'image/jpeg',
getFormattingForItem: item => {
// fake an upload
return new Promise(resolve => {
setTimeout(() => {
const url = URL.createObjectURL(item);
resolve({
text: `![${item.name}](${url})`,
config: { block: true },
});
}, 1000);
});
},
},
];
export default (props) => {
const [value, setValue] = useState(props.content);
const [messages, setMessages] = useState([]);
const [ast, setAst] = useState(null);
const [isAstShowing, setIsAstShowing] = useState(false);
const onParse = useCallback((err, { messages, ast }) => {
setMessages(err ? [err] : messages);
setAst(JSON.stringify(ast, null, 2));
}, []);
return (
<>
{isAstShowing && {ast} }
>
);
};
// tabs.js
import React, { useState, Fragment } from 'react';
import {
EuiIcon,
EuiTabs,
EuiTab,
EuiSpacer,
} from '@elastic/eui';
const tabs = [
{
id: 'cobalt',
name: (
cobalt.md
),
disabled: false,
},
{
id: 'dextrose',
name: (
dextrose.md
),
disabled: false,
},
{
id: 'hydrogen',
name: (
Hydrogen
),
disabled: false,
},
{
id: 'monosodium_glutammate',
name: (
monosodium_glutammate.md
),
disabled: false,
},
{
id: 'elastic_link',
name: (
elastic_link.md {alert('close me?')}}>
),
disabled: false,
},
];
export default (props) => {
const [selectedTabId, setSelectedTabId] = useState('cobalt');
const onSelectedTabChanged = id => {
setSelectedTabId(id);
if (props && props.onTabSelected)
props.onTabSelected(id);
};
const renderTabs = () => {
return tabs.map((tab, index) => (
onSelectedTabChanged(tab.id)}
isSelected={tab.id === selectedTabId}
disabled={tab.disabled}
key={index}>
{tab.name}
));
};
return (
{renderTabs()}
);
};
这样,我们点击不同的标签就能看到不同的内容了。
4. 托管
想要将我们的应用托管在GitHub,需要做一下几步:
- 执行
npm build
命令进行编译; - 然后在将远程仓库中的
gh-pages
拉取到本地; - 清空
gh-pages
分支; - 对编译出的文件做些调整,因为编译的路径如果不做修改很多文件提示找不到;
- 将修改后的文件复制到
gh-pages
目录; - 提交。
整个过程的命令如下(Linux下),唯一需要修改的就是仓库地址:
# 一下内容位于 my-app/Makefile
publish_github_pages:
rm -rf ./build
rm -rf ./gh-pages
npm run build
git clone --depth=1 https://github.com/SunnyZhou-1024/eui-markdown-editor.git --branch gh-pages ./gh-pages 2>&1 > /dev/null
rm -rf ./gh-pages/*
cp -R ./build/* ./gh-pages/
sed -i -e "s/\/static/.\/static/g" ./gh-pages/index.html
sed -i -e "s/\/favi/.\/favi/g" ./gh-pages/index.html
sed -i -e "s/\/logo/.\/logo/g" ./gh-pages/index.html
sed -i -e "s/\/mani/.\/mani/g" ./gh-pages/index.html
git -C ./gh-pages add --all
git -C ./gh-pages commit --amend --no-edit
git -C ./gh-pages push --force origin gh-pages
5. 总结
由于篇幅所限,在本文例子中,只选取了一些关键的点来讲解,主要讲解的是从如何搭建一个React App以及React 元素如何更新,到将其部署到GitHub的整个流程。具体代码逻辑可能和一个真正的编辑器有很大出入,并且非常不完善,例如显示的内容应该来自文件而不是硬编码。
本文的目的并不是讲解如何写出漂亮的UI,这不是我擅长的;也不是深入的讲解React,这一部分我觉得React官网的文档已经非常完善了;更不是介绍如何使用EUI。本文的只是想以EUI为例,介绍如何通过现有的UI框架、工具链构建起一个可用,也还看得过去的网页应用。
本文例子代码位于个人GitHub仓库:https://github.com/zmychou/eui-markdown-editor
6. References
[1] https://reactjs.org/docs/getting-started.html
[2] https://create-react-app.dev/docs/getting-started/
[3] https://elastic.github.io/eui/#/