最近看了掘金刚上线的在线代码编辑器 “码上掘金”,突然想是不是自己也可以写一个在线代码编辑器。
其实在线代码编辑器很早就存在了,例如: CodePen,CodeSanbox,JSFiddle
等等都是大家耳熟能详的。这些编辑器给开发者提供了这样的使用场景:当没有机会使用代码编辑器应用程序时,或者当你想使用计算机甚至手机快速尝试 Web 上的某些内容时,在线 Web 代码编辑器就会进行我们的视野。
本篇文章我希望和大家一起,尝试创建一个在线的 Web 代码编辑器,并在 HTML、CSS 和 JavaScript
的帮助下实时显示结果。我在本文的最后也放置了源代码的下载链接。
我认为这也是一个有趣的项目,因为了解如何构建代码编辑器将使你了解到做这个项目需要处理哪些功能模块。我们第一个需要了解的模块是 CodeMirror
。
我们将使用一个名为 CodeMirror
的库来构建我们的编辑器。CodeMirror 是一个用 JavaScript 实现的通用文本编辑器。它特别适用于编辑代码,并带有多种语言模式和附加组件,可实现更高级的编辑功能。同时,CodeMirror
带有丰富的 API 和 主题模式可以帮助你扩展应用的功能。
接下来,我们进入正题,开始构建这个项目。
我们先从创建一个新的 React 项目开始。在命令行中,创建一个 React 应用程序并将其命名为 web-code-editor:
npx create-react-app web-code-editor
同时,因为此时 creat-react-app 安装的是 react 18版本,考虑到兼容性,本文需要指定 react 的版本为 17.x。请修改 package.json 的依赖:
"dependencies": {
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.2",
"@testing-library/user-event": "^12.5.0",
"codemirror": "^5.59.1",
"react": "^17.0.1",
"react-codemirror2": "^7.2.1",
"react-dom": "^17.0.1",
"react-scripts": "4.0.1",
"web-vitals": "^0.2.4"
},
然后删除node_modules
文件夹,并重新执行 npm install
重新安装依赖。
我们可以看到,我们在依赖中安装了两个库:codemirror
和 react-codemirror2
。安装成功后,node_modules\codemirror
文件夹下会有如下目录,这是我们后面要用到的:
接着,替换掉 src\index.js
文件夹的内容为如下代码:
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
创建了新的 React 应用程序后,让我们在命令行中 cd
到该项目的目录:
cd web-code-editor
接下来,我们要创建三个选项卡,分别用于 HTML、CSS 和 JavaScript
代码的编辑。
接下来,我们将创建一个通用的按钮组件,用于选项卡中。
在 src
文件夹中创建一个名为 components
的文件夹。在这个新的组件文件夹中,创建一个名为 Button.jsx
的 JSX
文件。
以下是 Button 组件所需的代码:
import React from 'react'
const Button = ({title, onClick}) => {
return (
<div>
<button
style={{
maxWidth: "140px",
minWidth: "80px",
height: "30px",
marginRight: "5px"
}}
onClick={onClick}
>
{title}
</button>
</div>
)
}
export default Button
上面代码中,我们做了以下几件事:
title
和 onClick
。在这里,title
是一个文本字符串,onClick
是一个在单击按钮时调用的函数。style
属性来设置按钮的样式。{title}
作为按钮标签的内容现在我们已经创建了一个可重用的按钮组件,让我们继续将我们的组件引入 App.js
。请移步到 App.js 并导入新创建的按钮组件:
import Button from './components/Button';
要跟踪打开的选项卡或编辑器,我们需要声明一个 state
来保存打开的编辑器的值。使用 useState
钩子,我们将该 state
存储单击该选项卡按钮时当前打开的编辑器选项卡的名称。
代码如下:
import React, { useState } from 'react';
import './App.css';
import Button from './components/Button';
function App() {
const [openedEditor, setOpenedEditor] = useState('html');
return (
<div className="App">
</div>
);
}
export default App;
上述代码中,值 html
作为 state
的默认值传递,所以 HTML 编辑器将是默认打开的选项卡。
让我们继续编写函数,该函数将使用 setOpenedEditor
来更改单击选项卡按钮时的 state
值。
注意:这里可能不会同时打开两个选项卡,所以我们在编写函数时需要考虑到这一点。
代码如下:
import React, { useState } from 'react';
import './App.css';
import Button from './components/Button';
function App() {
...
const onTabClick = (editorName) => {
setOpenedEditor(editorName);
};
return (
<div className="App">
</div>
);
}
export default App;
在这里,我们传递了一个函数参数,它是当前选择的选项卡的名称。
接着继续为三个选项卡创建 Button 的三个实例:
<div className="App">
<p>欢迎进入 Web Code Editor !</p>
<div className="tab-button-container">
<Button title="HTML" onClick={() => {
onTabClick('html')
}} />
<Button title="CSS" onClick={() => {
onTabClick('css')
}} />
<Button title="JavaScript" onClick={() => {
onTabClick('js')
}} />
</div>
</div>
接着,我们使用三元运算符有条件地显示选项卡的内容:
...
return (
<div className="App">
...
<div className="editor-container">
{
openedEditor === 'html' ? (
<p>HTML editor</p>
) : openedEditor === 'css' ? (
<p>CSS editor</p>
) : (
<p>JavaScript editor</p>
)
}
</div>
</div>
);
...
上面代码中,如果 openedEditor
的值为html,则显示 HTML 部分。否则,如果openedEditor 的值为 css,则显示 CSS 部分。否则,如果该值既不是 html 也不是 css,那么这意味着该值必须是 js。
我们对三元运算符条件中的不同部分使用了 p
标签 。后面我们将创建编辑器组件并用编辑器组件本身替换 p 标签。
目前的效果如下所示:
我们希望按钮显示在网格中,而不是像上图那样垂直堆叠。那么移步到你的 App.css
文件并将 App.css
的中内容全部删去,接着填入以下代码:
.tab-button-container{
display: flex;
}
在 App.js
中我们添加了 className="tab-button-container"
作为包含三个选项卡按钮的 div 标记中的样式属性类。在这里,我们设置了该容器的样式,使用 CSS 将其显示设置为 flex。
在下一节中,我们将创建我们的编辑器,用它们替换 p 标签。
因为我们已经在 CodeMirror 编辑器中安装了要处理的库,所以让我们继续在 components
文件夹中创建 Editor.jsx
文件。创建新文件后,让我们在其中编写一些初始代码:
import React, { useState } from 'react';
import 'codemirror/lib/codemirror.css';
import { Controlled as ControlledEditorComponent } from 'react-codemirror2';
const Editor = ({ language, value, setEditorState }) => {
return (
<div className="editor-container">
</div>
)
}
export default Editor
上述代码中:
在我们的函数组件中,我们从 props 中解构了一些值,包括language、value和 setEditorState
。当在 App.js 中调用编辑器时,这三个 prop 将在编辑器的任何实例中提供。
让我们使用 ControlledEditorComponent
为我们的编辑器编写代码。代码如下:
import React, { useState } from 'react';
import 'codemirror/lib/codemirror.css';
import 'codemirror/mode/xml/xml';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/css/css';
import { Controlled as ControlledEditorComponent } from 'react-codemirror2';
const Editor = ({ language, value, setEditorState }) => {
return (
<div className="editor-container">
<ControlledEditorComponent
onBeforeChange={handleChange}
value= {value}
className="code-mirror-wrapper"
options={{
lineWrapping: true,
lint: true,
mode: language,
lineNumbers: true,
}}
/>
</div>
)
}
export default Editor
上述代码中:
CodeMirror 的 mode
指定编辑器适用于哪种语言。我们导入了三种模式,因为我们有这个项目的三个编辑器:
codemirror/mode/xml/xml
) 模式适用于 HTML。codemirror/mode/javascript/javascript
) 模式适用于 JavaScript。注意:因为编辑器是作为可重用的组件构建的,所以我们不能在编辑器中直接把模式写死。所以,我们通过我们解构的
language
来提供模式。
接下来,我们来讨论一下 ControlledEditorComponent 中的东西:
onBeforeChange
每当你向编辑器写入或从编辑器中删除时,都会调用此方法。可以将其想象为通常在输入字段中用于跟踪更改的 onChange
处理程序。使用它,我们将能够在有新更改的任何时候获取编辑器的值并将其保存到编辑器的状态。value = {value}
这只是编辑器在任何给定时间的内容。我们将一个名为 value
的 prop 传递给该属性。 value
保存该编辑器值的状态。这将由编辑器的实例提供。className="code-mirror-wrapper"
这个类名不是我们自己设置的样式。 它由我们在上面导入的 CodeMirror 的 CSS 文件提供。options
这是一个具有我们希望编辑器具有的不同功能的对象。CodeMirror 中有许多令人惊叹的选项。让我们看看我们在这里使用的那些:lineWrapping: true
这意味着当行满时代码应该换行到下一行。lint: true
允许检测提示。mode:language
如上所述,此模式采用编辑器将要使用的语言。上面已经导入了语言,但是编辑器将根据通过 prop
提供给编辑器的 language
值应用语言。lineNumbers: true
这指定编辑器应该有每一行的行号。接下来,我们为 onBeforeChange
处理程序编写 handleChange
函数:
const handleChange = (editor, data, value) => {
setEditorState(value);
}
我们只需要value
,因为它是我们想要在 setEditorState
属性中传递的值。 setEditorState
属性代表我们在 App.js 中声明的每个状态的值,保存每个编辑器的值。完整代码如下:
import React, { useState } from 'react';
import 'codemirror/lib/codemirror.css';
import 'codemirror/mode/xml/xml';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/css/css';
import { Controlled as ControlledEditorComponent } from 'react-codemirror2';
const Editor = ({ language, value, setEditorState }) => {
const handleChange = (editor, data, value) => {
setEditorState(value);
}
return (
<div className="editor-container">
<ControlledEditorComponent
onBeforeChange={handleChange}
value= {value}
className="code-mirror-wrapper"
options={{
lineWrapping: true,
lint: true,
mode: language,
lineNumbers: true,
}}
/>
</div>
)
}
export default Editor
接下来,我们将添加一个下拉菜单,允许我们为编辑器选择不同的主题。
CodeMirror 有多个主题可供我们选择。访问官方网站以查看可用的不同主题的演示。
让我们创建一个包含不同主题的下拉列表,用户可以在我们的编辑器中选择这些主题。本文中,我们将添加五个主题,但你可以添加任意数量的主题。
首先,让我们在 Editor.jsx 组件中导入我们的主题:
import 'codemirror/theme/dracula.css';
import 'codemirror/theme/material.css';
import 'codemirror/theme/mdn-like.css';
import 'codemirror/theme/the-matrix.css';
import 'codemirror/theme/night.css';
接下来,创建一个包含我们导入的所有主题的数组:
const themeArray = ['dracula', 'material', 'mdn-like', 'the-matrix', 'night']
让我们声明一个 useState 挂钩来保存所选主题的值,并将默认主题设置为 dracula
:
const [theme, setTheme] = useState("dracula")
让我们创建下拉列表:
...
return (
<div className="editor-container">
<div style={{marginBottom: "10px"}}>
<label for="cars">选择主题: </label>
<select name="theme" onChange={(el) => {
setTheme(el.target.value)
}}>
{
themeArray.map( theme => (
<option value={theme}>{theme}</option>
))
}
</select>
</div>
// ...
</div>
)
...
在上面的代码中,我们使用 label
标签向我们的下拉列表添加标签,然后添加 select
标签来创建我们的下拉列表。
因为我们需要用我们创建的 themeArray 中的主题名称填充下拉列表,所以我们使用 .map
数组方法来映射 themeArray
并使用 option
标签单独显示名称。
同时,在选择标签时,我们传递了 onChange
属性来跟踪和更新主题状态。每当在下拉列表中选择一个新选项时,该值都是从返回给我们的对象中获取的。接下来,我们使用 state hook
中的 setTheme
将新值设置为 state
持有的值。
至此,我们已经创建了下拉菜单,设置了主题的状态,并编写了函数来使用新值设置状态。为了使 CodeMirror 使用我们的主题,我们需要做的最后一件事是将主题传递给 ControlledEditorComponent
中的 option
对象。在 option
对象中,让我们添加一个名为 theme
的值,并将其值设置为所选主题的状态值。
这是 ControlledEditorComponent 现在的样子:
<ControlledEditorComponent
onBeforeChange={handleChange}
value= {value}
className="code-mirror-wrapper"
options={{
lineWrapping: true,
lint: true,
mode: language,
lineNumbers: true,
theme: theme,
}}
/>
现在,我们就已经添加了一个可以在编辑器中选择的不同主题的下拉列表。
下面是 Editor.jsx 中的完整代码目前的样子:
import React, { useState } from 'react';
import 'codemirror/lib/codemirror.css';
import 'codemirror/mode/xml/xml';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/css/css';
import 'codemirror/theme/dracula.css';
import 'codemirror/theme/material.css';
import 'codemirror/theme/mdn-like.css';
import 'codemirror/theme/the-matrix.css';
import 'codemirror/theme/night.css';
import { Controlled as ControlledEditorComponent } from 'react-codemirror2';
const Editor = ({ language, value, setEditorState }) => {
const [theme, setTheme] = useState("dracula")
const themeArray = ['dracula', 'material', 'mdn-like', 'the-matrix', 'night']
const handleChange = (editor, data, value) => {
setEditorState(value);
}
return (
<div className="editor-container">
<div style={{marginBottom: "10px"}}>
<label for="cars">选择主题: </label>
<select name="theme" onChange={(el) => {
setTheme(el.target.value)
}}>
{
themeArray.map( theme => (
<option value={theme}>{theme}</option>
))
}
</select>
</div>
<ControlledEditorComponent
onBeforeChange={handleChange}
value= {value}
className="code-mirror-wrapper"
options={{
lineWrapping: true,
lint: true,
mode: language,
lineNumbers: true,
theme: theme,
}}
/>
</div>
)
}
export default Editor
接着,我们转到 App.css
添加一个 editor-container
样式:
.editor-container{
padding-top: 0.4%;
}
现在我们的编辑器已经准备好了,让我们回到 App.js 并在那里使用它们。
我们需要做的第一件事是在此处导入 Editor.jsx 组件:
import Editor from './components/Editor';
在 App.js 中,让我们分别声明保存 HTML、CSS 和 JavaScript 编辑器内容的状态。
const [html, setHtml] = useState('');
const [css, setCss] = useState('');
const [js, setJs] = useState('');
这些状态会作为内容提供给给编辑器组件。
接下来,让我们将条件渲染中用于 HTML、CSS 和 JavaScript 的 p
标记替换为我们刚刚创建的编辑器组件:
function App() {
...
return (
<div className="App">
<p>欢迎进入 Web Code Editor !</p>
<div className="tab-button-container">
<Button title="HTML" onClick={() => {
onTabClick('html')
}} />
<Button title="CSS" onClick={() => {
onTabClick('css')
}} />
<Button title="JavaScript" onClick={() => {
onTabClick('js')
}} />
</div>
<div className="editor-container">
{
openedEditor === 'html' ? (
<Editor
language="xml"
value={html}
setEditorState={setHtml}
/>
) : openedEditor === 'css' ? (
<Editor
language="css"
value={css}
setEditorState={setCss}
/>
) : (
<Editor
language="javascript"
value={js}
setEditorState={setJs}
/>
)
}
</div>
</div>
);
}
export default App;
上述代码中:我们用编辑器组件的实例替换了 p 标签。然后,我们分别提供了它们的language、value和 setEditorState
属性,以匹配它们对应的状态。
效果如下:
我们将使用内联框架 (iframe) 来显示在编辑器中输入的代码的结果。
MDN: HTML 内联框架元素 (
<iframe>
) 表示嵌套的浏览上下文,将另一个 HTML 页面嵌入到当前页面中。
iframe 通常与纯 HTML 一起使用。将 iframe 与 React 一起使用不需要很多更改,主要是将属性名称转换为驼峰式。一个例子是 srcdoc
会变成 srcDoc
。
让我们继续,在 App.js
中创建一个 iframe 来容纳我们的编辑器的结果。
return (
<div className="App">
// ...
<div>
<iframe
srcDoc={srcDoc}
title="output"
sandbox="allow-scripts"
frameBorder="1"
width="100%"
height="100%"
/>
</div>
</div>
);
在这里,我们创建了 iframe 并将其存放在 div 容器标签中。在 iframe 中,我们传递了一些我们需要的属性:
srcDoc
属性是用驼峰写的,因为这是在 React 中编写 iframe 属性的方法。使用 iframe 时,我们可以在页面上嵌入外部网页或呈现指定的 HTML 内容。要加载和嵌入外部页面,我们将使用 src
属性。 在我们的例子中,我们没有加载外部页面;相反,我们想创建一个新的内部 HTML 文档来存放我们的结果。为此,我们需要 srcDoc
属性。该属性采用我们想要嵌入的 HTML 文档。allow-scripts
值运行。因为我们正在使用 JavaScript 编辑器,所以这会很快派上用场。让我们继续并声明将保存 srcDoc 的 HTML 模板文档的状态。如果你仔细查看上面的代码块,你会发现我们向 srcDoc 属性传递了一个值:srcDoc={srcDoc}
。让我们使用 useState() hook
来声明 srcDoc 状态。为此,在 App.js 文件中,转到我们定义其他 State 的位置并添加以下状态:
const [srcDoc, setSrcDoc] = useState(` `);
现在我们已经创建了 state,接下来要做的就是在我们在代码编辑器中输入时在状态中显示结果。 但有一点值得注意,就是我们不希望在每次输入时都重新渲染组件,这就涉及到后续优化的地方。
每当 HTML、CSS 和 JavaScript 的任何编辑器分别发生变化时,我们都希望触发 useEffect()
,这将在 iframe 中呈现更新的结果。让我们在 App.js
文件中编写 useEffect()
来执行此操作:
首先,导入 useEffect()
钩子:
import React, { useState, useEffect } from 'react';
代码如下:
useEffect(() => {
const timeOut = setTimeout(() => {
setSrcDoc(
`
<html>
<body>${html}</body>
<style>${css}</style>
<script>${js}</script>
</html>
`
)
}, );
return () => clearTimeout(timeOut)
}, [html, css, js])
在这里,我们编写了一个 useEffect()
hook,只要我们为 html、css 和 js 编辑器声明的值状态发生更改或更新,该 hook 就会运行。
你可能会问:为什么我们需要使用
setTimeout()
?
如果我们在没有它的情况下编写它,那么每次在编辑器中按下一个键,我们的 iframe 都会更新,这通常不利于性能。所以我们使用 setTimeout() 将更新延迟 250 毫秒,让我们有足够的时间知道用户是否还在打字。也就是说,每次用户按下一个键时,它都会重新开始计数,因此 iframe 只会在用户空闲(未键入)250 毫秒时更新。这是避免每次按下键时都必须更新 iframe 的一种很酷的方法。
在我们的代码中,我们传递了一个 HTML 模板,获取包含用户在 HTML 编辑器中键入的代码的 html 状态,并将其放置在模板的 body 标记之间。我们还获取了包含用户在 CSS 编辑器中输入的样式的 css 状态,并在样式标签之间传递了它。最后,我们获取了包含用户在 JavaScript 编辑器中键入的 JavaScript 代码的 js 状态,并在脚本标签之间传递了它。从而这就形成了一个包含 HTML、CSS、Javascript的网页。
请注意,在设置 setSrcDoc
时,我们使用了反引号 (``)
而不是普通引号 (' ')
。这是因为反引号允许我们传入相应的状态值,就像我们在上面的代码中所做的那样。
useEffect() 钩子中的 return 语句是一个清理函数,它在完成时清除 setTimeout()
,以避免内存泄漏。
以下是我们的项目目前的样子:
使用 CodeMirror 插件,我们可以使用其他代码编辑器中的更多功能来增强我们的编辑器。让我们来看一个在输入开始标签时自动添加结束标签的示例,以及在输入开始括号时自动结束括号的另一个示例:
首先要做的是将插件导入到我们的 Editor.jsx
文件中:
import 'codemirror/addon/edit/closetag';
import 'codemirror/addon/edit/closebrackets';
让我们在 ControlledEditorComponent
选项中传递它:
<ControlledEditorComponent
...
options={{
...
autoCloseTags: true,
autoCloseBrackets: true,
}}
/>
以上代码中,我们实现了标签自动补全的功能。
当然,如果你想的话,你可以将大量这些插件添加到你的编辑器中,以使其具有更丰富的功能。本文中,我们就不尝试所有功能了。
至此,我们大致完成了一个在线编辑器的应用。接下来,我想和大家讨论一下关于如何提升应用性能和可访问性。
看看我们的代码编辑器,有些东西肯定是可以改进的。为了获得更好的可访问性,你可以采取以下措施来改进:
active
类,高亮显示该按钮。这样可以让用户清楚地知道他们当前正在使用哪个编辑器,从而提高可访问性。iframe
的安全问题,主要是因为我们在 iframe 中加载了内部 HTML 文档,而不是外部文档。所以我们不需要考虑太多,因为 iframe 非常适合我们的用例。当你构建任何应用程序时,性能和可访问性都值得考虑很多,因为它们将决定你的应用程序对其用户的有用性和可用性。
本文创建的 Web 代码编辑器还有很多可以改进的地方,希望你能在此基础上做很多的扩展,丰富编辑器的功能与界面!