前言:作为一名程序员,我们工作中的每一天都在与代码编辑器打交道,相信各位前端程序员对 VS Code 一定都不陌生,VS Code 可以为我们提供代码高亮、代码对比等等功能,让我们在开发的时候,不需要对着暗淡无光的脚本编辑,而是对着五彩缤纷的界面编辑,并且一个右键或者快捷键,编辑器就可以帮我们实现很多的功能。这个系列我讲述的是 VS Code 的 弟弟 – Monaco Editor,它是一款基础代码编辑器,VS Code 内部的代码编辑模块和它是一样一样的,只不过 VS Code 比它增加了文件管理系统,可以通过Node.js操作文件。
另外,Monaco Editor 特别有出息,市面上很多的浏览器端的代码编辑器都有 Monaco Editor 的身影,例如 github的浏览器端代码编辑器
等等我就不举例啦
为什么要学习 Monaco Editor ?
对于我自己来说,是折服于代码编辑器的丝滑体验和强大功能,好奇内部是怎么实现的,另外就是通过学习这么一个优秀的项目的源码,学习优秀的编码思维和编码技巧。
下面迫不及待的进入 Monaco Editor 的学习吧!
环境说明
🥗 Macbook Apple芯片
🥗 Node16
🥗 编辑器 idea2023 (虽然我讲了 vs code 的弟弟,但我用的是 idea 哈哈哈,主要是平时开发前后端不分离,用习惯了🤣)
一、启动项目
1、下载源码
源码地址:github源码地址
既然我们要学习Monaco Editor的源码,就需要把源码下载下来,而不是使用 npm 安装,那样只会作为依赖出现在项目里,而现如今,Monaco Editor 从依赖变成了研究对象,转正了!
安装方式有两种,一种是在命令行使用 git clone https://github.com/microsoft/monaco-editor.git
,安装,但是我的网络不争气,下载不成功,所以只能用笨蛋方法,下载压缩包,我的版本是 0.46.0
2、使用编辑器打开项目,在项目的根目录下运行 npm i .
安装依赖
3、编译本地项目
package.json
中有编译项目的命令的定义
运行 npm run build-monaco-editor
,会构建出来一个本地的 monaco-editor。
4、进入本地的 monaco-editor 目录: cd website
5、在当前 /website 目录安装依赖:npm i .
6、然后要编译typescript,根据 package.json 中的命令定义,运行 npm run typedoc
此处我运行之后报错了
omg,源码也出错吗?我产生了深深的怀疑,于是就怀疑自己的Node.js的版本不兼容,为此特地下载了 nvm 工具切换了好几个版本的 Node,报错依然如旧,好嘛,还是老老实实分析一下代码。这里的报错就是说有几个变量是undefined,所以取不到它的name属性,typescript 使用对象时提供了安全保护,只需要增加一个 ?
,就可以规避对象不存在的风险,所以这里就是给出错的几个可能不存在的对象后面加一个 ?
然后在运行 npm run typedoc
就成功了,并且 typedoc 文件夹中多出来了一个 dist 目录,里面存放的应该就是将 typescript 编译之后的结果咯,
7、启动项目
运行 npm run dev
然而此时又出现了一丢丢的报错,不要慌!定睛一看,是在说 webpack.config.ts
里面的插件的引入方法不对
改成
import CopyPlugin from "copy-webpack-plugin";
这两个都需要改一下引入方式,改成下面这种,虽然飘红,但是能用
就可以啦!!
npm run dev 成功!!
本地的 Monaco Editor 就是介个样子的啦
二、入口文件
1、寻找入口文件
咱们这个项目使用 webpack 构建的,这么一堆代码,该从哪里开始看呢?其实就是从 webpack.config.ts 开始,因为它会告诉我们谁是入口文件!
entry: {
index: r("src/website/index.tsx"),
playgroundRunner: r("src/runner/index.ts"),
monacoLoader: r("src/website/monaco-loader-chunk.ts"),
},
根据 webpack.config.ts
的配置我们可以知道,入口文件就是 src/website/index.tsx。咦,文件路径前面的 r
是什么意思呢?其实是定义的读取文件的方法,这也是我们在实际开发中可以借鉴的一个小技巧
const r = (file: string) => path.resolve(__dirname, file);
关于 chunk 的理解,可以参考文章:webpack 理解 chunk
webpack 的作用就是将我们写的代码模块 module
打包在一起,模块就是我们编写的所有文件。chunk
就是 webpack 在打包的过程中,一些 module
的集合。Webpack 的打包是通过入口文件开始的,入口文件会引用其他模块,其他模块还会引用其他模块,webpack 从入口文件开始,把一系列引用的模块打包为一个 chunk。
当前的入口是对象的形式,每一个属性都会生成一个 chunk,属性名会被当成chunk的名称。
从这里的 entry
我们可以看到,当前的项目是一个多页面应用,每个页面都需要通过 HtmlWebpackPlugin
插件生成页面模版文件,生成页面模版文件的代码在下面的 plugins
配置项里面,这里我们先只关注 index chunk 下的配置
new HtmlWebpackPlugin({
chunks: ["monacoLoader", "index"],
templateContent: getHtml(),
chunksSortMode: "manual", // 控制排序:手动,就是根据 chunks 数组的顺序依次引入
}),
new HtmlWebpackPlugin({
chunks: ["index"],
filename: "playground.html",
templateContent: getHtml(),
}),
new HtmlWebpackPlugin({
chunks: ["index"],
filename: "docs.html",
templateContent: getHtml(),
}),
new HtmlWebpackPlugin({
chunks: ["index"],
filename: "monarch.html",
templateContent: getHtml(),
}),
html-webpack-plugin 插件可以为应用程序生成 HTML 文件,具体的配置项可以查看 插件文档。
chunks
:这个属性主要在多页面应用的时候才有用哦!webpack 打包的过程中会生成多个 chunk,这些 chunk 其实是 js 文件,使用chunks
属性就可以指定当前生成的这个 HTML 使用哪一个 js 文件filename
:生成的html文件的文件名,默认为index.html
templateContent
:模版文件chunksSortMode
:是用来控制 HTML 所引用的 chunk 的顺序,可选项:‘none’ | ‘auto’ | ‘manual’ | {Function}。详细的含义就不解释了,这里使用的manual
就是按照chunks
数组的顺序引入资源
以第一个 HtmlWebpackPlugin
为例,这个文件没有设置 filename
属性,生成的就是根路由对应的页面,也就是请求 localhost 根页面返回的 html 文件,这个文件需要依赖两个 chunk ,其实就是要依次执行这两个 chunk 对应的入口文件中的代码。
第一个是 monacoLoader
chunk,它的入口文件是 src/website/monaco-loader-chunk.ts,为了验证,我们可以在里面打印 111
第二个是 index
chunk ,对应的入口文件是 src/website/index.tsx,为了验证我们也打印 222
然后我们访问根目录,在控制台中的 network 栏,可以看到,第一个请求就是请求的 localhost 的根页面,页面会通过 script
标签依次引入上述的两个 chunk 生成的 js 文件
另外,我们看一下控制台输出,果然不出我所料,输出了 111 和 222
引入的几个 js 文件,就是 webpack 打包之后的结果,可以在 sources 栏看到
2、查看入口文件
index chunk 的入口文件的内容如下,我稍微做了一点小注释:
src/website/index.tsx
// 引入react
import * as React from "react";
import * as ReactDOM from "react-dom";
// 引入样式文件
import "./bootstrap.scss";
import "./style.scss";
// 引入应用
import { App } from "./pages/App";
// 创建 root 容器
const elem = document.createElement("div");
elem.className = "root";
// 插入到文档中
document.body.append(elem);
// 将应用渲染到 root 中
ReactDOM.render(<App />, elem);
3、应用的定义
接下来我们顺藤摸瓜,看一下 App 应用的定义吧!
src/website/pages/App.tsx
// 引入 Home 组件
import { Home } from "./home/Home";
// 引入 PlaygroundPage 组件
import { PlaygroundPage } from "./playground/PlaygroundPage";
// 引入 路由
import { docs, home, monarch, playground } from "./routes";
// 引入 React
import React = require("react");
// 引入 DocsPage
import { DocsPage } from "./DocsPage";
// 引入 MonarchPage
import { MonarchPage } from "./MonarchPage";
export class App extends React.Component {
// 根据路由返回指定的组件
render() {
if (home.isActive) {
return <Home />;
} else if (playground.isActive) {
return <PlaygroundPage />;
} else if (docs.isActive) {
return <DocsPage />;
} else if (monarch.isActive) {
return <MonarchPage />;
}
return <>Page does not exist</>;
}
}
App 干的最主要的事情,就是根据路由返回指定的页面。
插一句题外话,我发现我的项目执行 npm run dev
没有自动在浏览器中打开,如此不智能,还需要我动用发财的小手亲自点开链接,不可原谅!我们只需要在 webpack.config.ts 的 devServer 配置项中增加一个 open:true
配置就可以让我们的 Monaco 变聪明啦!另外还可以加上 hot:true
,就可以在我们更改代码的时候,自动刷新浏览器中网页的内容。
devServer: {
open: true,
hot: true,
//...
}
4、路由的配置
接下来我们来看一看路由的配置
src/website/pages/routes.ts
// 定义路由类
export class Route {
// Route 接收 href 为参数
constructor(public readonly href: string) {}
// 路由实例上的属性
// get 语法 访问对象的isActive属性时,得到的是 isActive() 方法的返回值
// isActive 通过url路径判断当前访问的url是对应哪一个路由
get isActive(): boolean {
const target = new URL(this.href, window.location.href);
return (
trimEnd(target.pathname, ".html") ===
trimEnd(window.location.pathname, ".html")
);
}
}
function trimEnd(str: string, end: string): string {
if (str.endsWith(end)) {
return str.substring(0, str.length - end.length);
}
return str;
}
// 通过new创建路由实例
export const home = new Route("./");
export const playground = new Route("./playground.html");
export const docs = new Route("./docs.html");
export const monarch = new Route("./monarch.html");
从路由配置和 App 的配置中可以看出,根路径对应的就是 Home 组件,/playground.html
路径对应的就是 Playground 组件,然后我们现在访问一下 /playground.html
路由,记得加后面的 .html
哦。就会展示代码编辑页面
三、Hello World案例
进入 Playground 组件,映入眼帘的就是 Monaco 给我们提供的 Hello World 案例,左侧是源码,右侧是展示效果。
1、左侧源码
怎么找代码的位置呢?咱们直接复制代码,全局搜索,这个 hello-world 文件夹就是
左侧区域的三个编辑区域就分别是该文件夹中的 js、html、css 文件的内容
源码定义的部分找到了,但是它是怎么来到编辑框里的呢?
其实找源码这个过程可以有两条路,一条是通过 html
中的类名,找模版中的定义,一条是通过 /hello-world
这个文件夹的路径找,因为肯定有地方通过 import
引入了这几个文件。 实不相瞒,第一条路我走过了,有点曲折,所以直接说第二条路。
通过全局搜索 /hello world
可以找到website/src/website/data/playground-samples/all.js 中引入了所有示例文件定义的数据,并且将这些文件和对应的内容定义为json文件,放到了变量 PLAY_SAMPLES
中。
那么此时我们再全局搜索一下 all.js
路径,看看哪个地方引入了这个文件,好吧,并没有找到。不要心灰,不要失望,这是全局搜索一下它的父文件夹 playground-samples,就会发现一个可疑的目标website/src/website/pages/playground/playgroundExamples.tsx
在这个文件里,调用了 require.context()
方法,这个方法是 Webpack 的api,用来检索目录内容,
// require.context:
// 参数一:表示检索的目录
// 参数二:表示是否检索子文件夹
// 参数三:匹配文件的正则表达式
const descriptions = require.context<{ title: string; sortingKey?: number }>(
"../../data/playground-samples",
true,
/json$/
);
这是把所有的 json 文件都检索了一遍,我们在控制台看一下输出,发现返回值是一个方法
返回值可以通过 .keys().forEach()
的方式遍历文件夹中的所有文件
descriptions.keys().forEach(item=>{
console.log(item)
})
打印一下发现是文件夹中所有 json 文件的路径
website/src/website/pages/playground/playgroundExamples.tsx 文件最后导出了一个方法 getPlaygroundExamples()
,这个方法最后有一个返回值 result
。中间的具体过程先不看,先打印一下这个 result
,是一个下图所示,保存了所有的示例数据的数组
虽然这里面并没有 html 、js 、 css 文件内容 🥹,但是有一个 load()
方法
website/src/website/pages/playground/playgroundExamples.tsx 中有定义
async load() {
const [css, js, html] = await Promise.all([
files(path + "/sample.css"),
files(path + "/sample.js"),
files(path + "/sample.html"),
]);
return {
css: css.default,
html: html.default,
js: js.default,
};
},
所以执行 load() 方法就可以获取对应的 html 、css 、js 内容
那么一定有一个地方引用了这个方法,我们全局搜索一下,发现是在website/src/website/pages/playground/PlaygroundPageContent.tsx 里面
<Select<PlaygroundExample>
values={getPlaygroundExamples().map(
(e) => ({
groupTitle:
e.chapterTitle,
items: e.examples,
})
)}
value={ref(
model,
"selectedExample"
)}
getLabel={(i) =>
i.title
}
/>
看来返回值是切换示例的下拉框的数据
大胆猜测一下, 切换下拉框选项的时候,就会执行对应对象的 load()
方法,加载对应的 html、css、js 的内容,展示在页面上
如下代码控制了 value
的绑定,value
绑定了 model
对象上的 selectedExample
,当 value
变化的时候,就会触发 selectedExample
的 set
方法设置它的值
value={ref(
model,
"selectedExample"
)}
浅浅看一下 ref 的定义
export function ref<T, TProp extends keyof T>(
obj: T,
prop: TProp
): IReference<T[TProp]> {
return {
get: () => obj[prop],
set: (value) => (obj[prop] = value),
};
}
然后我们全局搜一下 selectedExample
对象的定义
在 website/src/website/pages/playground/PlaygroundModel.ts 方法里面找到了这个对象的 set
方法,即 value 变化的时候会执行的方法
public set selectedExample(value: PlaygroundExample | undefined) {
this._selectedExample = value;
this.selectedExampleProject = undefined;
if (value) {
value.load().then((p) => {
runInAction("update example", () => {
this.selectedExampleProject = {
example: value,
project: p,
};
this.reloadKey++;
this.setState(p);
});
});
}
}
这里终于看到了 load()
方法的执行,可以输出一下 p
,终于看到这几个内容了
setState()
方法就是赋值操作
public setState(state: IPlaygroundProject) {
this.html = state.html;
this.js = state.js;
this.css = state.css;
}
当前是 PlaygroundModel
类, PlaygroundPageContent
类里面,在创建的时候,传递了 props 参数
export class PlaygroundPageContent extends React.Component<
{ model: PlaygroundModel },
{}
> {}
在 PlaygroundPageContent
类的内部,可以通过 this.props.model
访问 PlaygroundModel
类
html、css、js 文件的编辑区域是自定义组件 Editor
<Editor
language={"javascript"}
value={ref(model, "js")}
/>
将 model.js
的值赋给 Editor 类中的 value
属性
进入到 Editor 类的内部
website/src/website/pages/playground/PlaygroundPageContent.tsx
private readonly model = getLoadedMonaco().editor.createModel(
this.props.value.get(),
this.props.language
);
render() {
return (
<MonacoEditor
model={this.model}
onEditorLoaded={(editor) => this.initializeEditor(editor)}
height={this.props.height}
className="editor-container"
/>
);
}
最后返回的是 MonacoEditor
自定义标签,我们在往这个类里瞅一瞅 MonacoEditor
类
注意这个文件里面定义了好几个类,别找错了哟
website/src/website/components/monaco/MonacoEditor.tsx
private readonly divRef = React.createRef<HTMLDivElement>();
render() {
const height = "100%";
return (
<div
style={{
height,
minHeight: 0,
minWidth: 0,
}}
className="monaco-editor-react"
ref={this.divRef}
/>
);
}
上面代码使用 ref 将变量 divRef 和元素 div 绑定在一起,在 componentDidMount
生命周期钩子函数中,有对这个数据的操作,即以当前的div为容器创建 Monaco Editor 编辑器实例
componentDidMount() {
// 获取 dom 元素
const div = this.divRef.current;
if (!div) {
throw new Error("unexpected");
}
this.resizeObserver.observe(div);
this.editor = getLoadedMonaco().editor.create(div, {
model: this.props.model,
scrollBeyondLastLine: false,
minimap: { enabled: false },
automaticLayout: false,
theme: this.props.theme,
readOnly: this.props.readOnly,
});
this.editor.onDidContentSizeChange((e) => {
this.setState({ contentHeight: e.contentHeight });
});
if (this.props.onEditorLoaded) {
this.props.onEditorLoaded(this.editor);
}
}
通过在浏览器中打断点,我发现渲染 html、css、js 的步骤是在 this.props.onEditorLoaded()
方法中实现的,这个方法是通过属性传递过来的,上面的代码里也有,可以全局搜一下
<MonacoEditor
model={this.model}
onEditorLoaded={(editor) => this.initializeEditor(editor)}
height={this.props.height}
className="editor-container"
/>
那么此时就是要执行 this.initializeEditor(editor)
方法,进行编辑器中内容的初始化。
initializeEditor(editor: monaco.editor.IStandaloneCodeEditor) {
this.editor = editor;
this.disposables.push(this.editor);
this.disposables.push(
this.editor.onDidChangeModelContent((e) => {
this.ignoreChange = true;
try {
this.props.value.set(this.editor!.getValue());
} finally {
this.ignoreChange = false;
}
})
);
}
最主要的一句代码就是 this.props.value.set(this.editor!.getValue());
,例如在这里写一个测试数据,页面上就会渲染测试数据:
this.props.value.set(this.editor.getValue());
至于 value 又是怎么渲染的,原谅此时的我实在是找不到啦!那么就往下继续看吧,不要让暂时的困难困住我们前进的脚步
2、右侧展示效果
右侧的效果就是左侧代码运行之后的结果,我们来验证一下,比如说在 html 里面,增加 Hello 文本,它就会直接出现在右侧
ok,到这里我发现不学react已经进行不下去了,所以我去react官网逛了一圈,React 官网提供了线上运行的案例,使用的是 codesandbox 线上代码编辑器,然而我发现这个网站用的也是我们的 Monaco !怪不得体验感这么好。不得不说 React 官网的教程真的清晰易懂,生动形象!但是翻译泰拉垮啦🙀 不如看英文
一万年以后~~~~ React 入门案例学成归来,继续学习 Monaco
接下来我们还是,首先要找到代码在哪里呀,通过检查模式可以看出,右侧部分是一个 iframe
,并且其父级类名是 preview
就可以使用类名full-iframe
进行全局搜索,父级类名还得是 preview
,发现了它的踪迹就在 /src/website/pages/playground/Preview.tsx 文件里
<iframe
className="full-iframe"
key={this.counter}
sandbox="allow-scripts allow-modals"
frameBorder={0}
ref={this.handleIframe}
src="./playgroundRunner.html"
/>
这个 iframe 的 src
的指向,就是它的具体内容。但是很奇怪,根据指定的目录,找不到 ./playgroundRunner.html
文件,再用这个文件名全局搜索一下子,发现奥秘原来在 webpack.config.ts
里面
module.exports = {
entry: {
index: r("src/website/index.tsx"),
playgroundRunner: r("src/runner/index.ts"),
monacoLoader: r("src/website/monaco-loader-chunk.ts"),
},
plugins: [
new HtmlWebpackPlugin({
chunks: ["playgroundRunner"],
filename: "playgroundRunner.html",
templateContent: getHtml(),
}),
]
}
介个 html 文件的内容,首先是由 templateContent
属性指定的模版确定了一部分。那么就让我们来揭开,这个属性对应的值 getHtml() 方法的真面目吧!其实非常非常的简单,就在 webpack.config.ts 的下面定义的,就是返回了一个基础的 Document 文档结构
function getHtml(): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Monaco Editor</title>
</head>
<body>
</body>
</html>`;
}
这个模版只是给出了html内容的模版,具体的内容还是要看 playgroundRunner
chunk 的入口文件执行的代码是怎么样的。它的入口文件就是src/runner/index.ts,里面的内容不得不说,还挺复杂。另外,要注意当前 chunk 生成的 html 文件是通过 iframe 的方式使用的哦!
这个入口文件做的事情,大致来说,就是给 iframe 增加了一个事件监听,用来监听父级 window 发送过来的数据,如果父级 window 发布了要该 iframe 初始化的命令,它就会乖乖的执行初始化的方法
父级传递给这个 iframe 的东东,其实就是左侧编辑区域的代码,咱们可以在初始化的方法里面打印一下这个方法接收到的参数
在初始化方法里面,会把参数中的 css、html 都加到当前 iframe 里面,并且会通过 eval()
方法,执行 js 里面的代码,执行完 js 里面的代码,右侧就会创建出来一个 Monaco Editor 编辑器实例啦,并且里面的内容是我们可以通过左侧编辑器修改的。
四、总结
本篇从 Monaco Editor 下载、启动开始,顺着入口文件以及示例 Hello World 项目的脉络,学习了源码的执行过程。学习这种大型项目的源码一定要有耐心,另外就是在学习的过程中需要查漏补缺,遇到不会的东西补一补,慢慢的积累。
参考文章:
1、https://juejin.cn/post/6844903889393680392
2、html-webpack-plugin
3、require.context()的用法详解