携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情
最近写文章的时候,用了一下掘金的前端代码在线编辑运行工具-码上掘金,感觉用来写个在线小demo
还是挺不错的,之前也用过 codepen等类似的工具,关于此类工具的原理我大概是有点了解的,但具体细节倒还不怎么清楚,于是趁此机会一探究竟
本文将完整实现码上掘金的核心功能(支持less
、typescript
、vue2
、vue3
、react
、react-ts
的在线编辑和预览),完整可运行代码已上传至 github,做了个 在线demo 有兴趣的可以试下
编辑器
关于在线编辑器,已经有很多开源的产品了,例如 CodeMirror、Ace、Monaco Editor,这几个都是比较成熟的产品随便选哪个都行,码上掘金用的是 Monaco Editor,这个是微软团队出品的且也是 VS Code
使用的编辑器,所以本文也就用这个了
看了下 Monaco Editor 的文档,配置项还是不少的,本来是打算自己封装一个 react
组件,后来发现已经有人封装好了:monaco-react,所以我也就没必要再折腾一遍,直接用这个就完事
import React from "react";
import ReactDOM from "react-dom";
import Editor from "@monaco-editor/react";
function App() {
return (
<Editor
height="90vh"
defaultLanguage="javascript"
defaultValue="// some comment"
/>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
defaultLanguage
用于告知编辑器我希望有个什么样的编程语言环境,这样编辑器会自动注册相关编程语言的开发环境和上下文,这样我们写代码的时候就会有智能提示和高亮
支持传入多种主流编程语言,包括我们这次用到的 javascript
、typescript
、html
、css
、less
,但对于 vue
、react
这种基于有 external dsl
的框架语言却是不支持的,不过开发者可以针对这些 dsl
自定义智能提示和高亮,但很明显这个工作量还是比较高的
于是我就想抄下码上掘金的相关逻辑,结果细看之下发现码上掘金对于这块的功能实现的也不咋滴,甚至有点糊弄(例如 ReactTS
连 jsx
语法都给你画红线 щ(`ω´щ) ),没办法只好自己去找找,找了半天终于找到了一份针对 vue dsl
的大概可用的自定义环境代码
虽然这个文件是针对 vue2
的,但 vue2
和 vue3
的 dsl
语法差别并不大,所以都可以用,那么我们就解决了 vue2/vue3
的语法环境了
// vue2
monacoInstance.languages.register({ id: themeMap[languages.vue2.id] })
monacoInstance.languages.setMonarchTokensProvider(themeMap[languages.vue2.id], vue2Language.syntax)
monacoInstance.editor.defineTheme(getCustomThemeNameByLanguage(languages.vue2.id), vue2Language.theme)
// vue3
monacoInstance.languages.register({ id: themeMap[languages.vue3.id] })
monacoInstance.languages.setMonarchTokensProvider(themeMap[languages.vue3.id], vue2Language.syntax)
monacoInstance.editor.defineTheme(getCustomThemeNameByLanguage(languages.vue3.id), vue2Language.theme)
至于 react
的dsl
只有 jsx
,monaco
倒是提供了对 jsx
的支持,不过不是那么完善,但在咱们的场景下也够用了
iframe
在编辑器里写好代码后,为了避免上下文混乱,我们一般都会将写好的代码放到沙箱环境中运行,在浏览器端实现沙箱环境最简单最保险的方式就是借助 iframe
,这也是码上掘金、codepen 等在线代码编辑运行工具的共同选择
由于码上掘金是有后端的,所以可以将代码传到后端,在后端通过 node
将代码编译好之后,再将页面内容回传给 iframe
,但我们没有后端,实际上也可以不需要后端,前端也可以完成编译工作,然后将编译好的代码传给 iframe
执行即可
与iframe
通信的方式很多,本文选择 postMessage
,主页面将编辑器内的 html
、style
、script
代码传给 iframe
,iframe
再分别处理这三类代码
window.addEventListener('message', e => {
const { html, style, script } = e.data.data
// 处理样式
const styleTag = document.createElement('style')
styleTag.innerHTML = style
document.head.appendChild(styleTag)
// 处理 html
document.body.innerHTML = html
// 处理 script
// 这里需要注意一下,因为可能涉及到操作 dom,所以 script 的处理应该放在 style 和 html 的下一个事件循环里
setTimeout(() => {
const scriptTag = document.createElement('script')
scriptTag.innerHTML = content
document.body.appendChild(scriptTag)
})
}
这里还有个问题,那就是需要注意 iframe
内上下文混乱的问题,例如在 script
代码编辑器里写了一行 const a = 1
,传给 iframe
处理后,iframe
的上下文环境里就有了 a
这个变量,如果你再次执行这个代码,iframe
就会报错: Identifier 'a' has already been declared
,所以在新的代码段进来之前,需要清空 iframe
的上下文,也很简单,location.reload()
一下就好
reload
的时机需要父页面来告知,那么父页面与 iframe
通信的内容就有两种了,一种是代码更新,一种是reload
,在一次代码更新过程中,父页面需要向 iframe
发送这两个事件,且这两个事件还需要有一个时间差
当发送 reload
时,iframe
刷新上下文,这个时刻父页面是不能立刻发送新代码片段让 iframe
执行的,因为这个时候 iframe
页面还被初始化好呢,需要等到 iframe
完成了 onload
事件之后,父页面才能继续发送新代码片段
// 父页面
window.addEventListener('message', (e: MessageEvent<{ event: 'loaded' }>) => {
if (e.data.event === 'loaded') {
// 知道 iframe 已经准备好了,可以将新代码片段交给 iframe 执行了
// ...
}
})
// iframe
window.onload = function() {
// 告诉父页面已经准备好了
window.parent.postMessage({
event: 'loaded'
})
}
window.addEventListener('message', e => {
// reload
if (e.data.event === 'reload') {
location.reload()
return
}
// 代码更新
// ...
})
另外,iframe
虽然可以直接作为一个沙箱环境,但其默认的权限可能会有点大,我们还可以通过一些属性来限制一些权限以提高安全性 例如下面是码上掘金给 iframe
设置的属性
<iframe
sandbox="allow-downloads allow-forms allow-pointer-lock allow-popups allow-presentation allow-same-origin allow-scripts allow-top-navigation-by-user-activation"
allow="accelerometer; camera; encrypted-media; display-capture; geolocation; gyroscope; microphone; midi; clipboard-read; clipboard-write; web-share"
scrolling="auto"
allowFullScreen
loading="lazy"
></iframe>
对于支持sandbox
的浏览器来说,sandbox
属性可以设置具体的值,表示逐项打开限制;未设置某一项,就表示不具有该权限
对于上述代码中的 sandbox
属性,其表示允许 iframe
:下载、表单提交、使用 Pointer Lock API
,锁定鼠标的移动、使用window.open()
方法弹出窗口、使用 Presentation API
、不默认将所有加载的网页都视为跨域、运行脚本、对顶级窗口进行导航,但必须由用户激活
allow
用于为iframe
指定其特征策略,例如允许 iframe
的摄像功能、地理定位、麦克风、支付请求等
scrolling
规定是否在 iframe
中显示滚动条,默认值是 auto
:在需要的情况下出现滚动条,另外还有 yes
:始终显示滚动条(即使不需要)、no
:从不显示滚动条(即使需要)
allowFullScreen
的值等同于 allow="fullscreen"
iframe
指定的网页会立即加载,有时这不是希望的行为,可以通过 loading
属性来指定这个行为:
- auto:浏览器的默认行为,与不使用
loading
属性效果相同 - lazy:开启懒加载,即将滚动进入视口时开始加载
- eager:立即加载资源,无论在页面上的位置如何
less
有时候使用less
这类css
预处理语言编写样式会更加方便,但浏览器是不认预处理语言编写的样式的,所以在追加给浏览器之前,需要将 css
预处理语言代码编译成 css
在实际开发中,我们一般借助 webpack
或 vite
等打包工具完成这个工作,例如对于 less
来说,只需要安装 less
和 less-loader
,然后在 webpack
上配置一下就好,但 webpack
是基于 node
的,我们没法在浏览器端用上node
,不过less
也提供了在浏览器端的 使用方法:将 less.js
引入到页面上,然后将 less
代码放到 style
标签内,这个 style
标签的 type
属性必须是 text/css
<script src="less.js" type="text/javascript"></script>
<link rel="stylesheet/less" type="text/css" href="styles.less" />
这是即时编译的思路,当然你也可以直接将 less
编译成 css
,然后传入 iframe
,这样就不用在 iframe
内引入 less.js
了:
import less from 'less'
const genLess2Css = async (lessStr: string) => {
return (await less.render(lessStr)).css
}
对于 scss
、stylus
等也是这个思路,这里就不多说了
typescript
浏览器也是不认 typescript
的类型系统的,所以在执行 ts
代码之前,需要将其编译成 js
,在实际开发过程中,我们会使用 babel
配合 webpack
来完成这个工作,同样的,babel
也提供了不需要借助 webpack
也能编译代码的库:@babel/standalone
babelStandalone.transform(originContent, {
filename: 'file.ts',
presets: ['typescript']
}).code
第一个参数是编写的 typescript
代码,第二个参数的 preset
传入一个数组项 typescript
,返回的 code
字符串就是编译后的代码
对于如下代码:
(document.getElementById('app') as HTMLDivElement).innerHTML = 'hello world by ts'
将编译为
document.getElementById('app').innerHTML = 'hello world by ts';
react
react
代码无法在浏览器端直接执行,所以在执行 react
代码之前,需要将其编译成 js
,同样可以借助 @babel/standalone 完成这个过程
babelStandalone.transform(originContent, {
presets: ['react']
}).code
与编译 typescript
代码不同的是,所需的 presets
为 react
例如对于以下代码
import React, { useState } from 'react'
import ReactDOM from 'react-dom'
function App() {
const [count, setCount] = useState(0)
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
)
}
ReactDOM.render(
<App />,
document.getElementById('app')
)
编译后得到
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
function App() {
const [count, setCount] = useState(0);
return /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("p", null, "You clicked ", count, " times"), /*#__PURE__*/React.createElement("button", {
onClick: () => setCount(count + 1)
}, "Click me"));
}
ReactDOM.render( /*#__PURE__*/React.createElement(App, null), document.getElementById('app'));
其实主要就是编译了 jsx
语法,如果直接将这段代码交给 iframe
执行肯定是不行的,因为在执行 import React, { useState } from 'react';
的时候会报错
现代浏览器已经支持使用 es module
的方式导入脚本
<script type="module">
import React, { useState } from 'react'
import ReactDOM from 'react-dom'
<script>
与一般脚本的区别是,需要给 script
标签加个 type="module"
的属性,import
、export
语法和你在webpack
中用到的差不多(本来webpack
也就是为了让你提前用上新的语法)
在webpack
中,类似于 import ReactDOM from 'react-dom'
这种写法,webpack
会默认到 node_module
中去找 reacu-dom
模块,但在浏览器端是没有 node_module
这个模块的,也没有这种默认行为,from
后的字符串必须是一个链接地址(可以是绝对地址也可以是相对地址),指向真正的 es module script
脚本
<script type="module">
import React, { useState } from 'https://pdn.zijieapi.com/esm/bv/[email protected]'
import ReactDOM from 'https://pdn.zijieapi.com/esm/bv/[email protected]'
<script>
改成上面这种,浏览器就能正常运行了
但如果就想像在 webpack
里那样引用也是可以的,import-maps
就是为了解决浏览器中的全局模块而出现的,目前只有基于 Chromium
支持这一特性,对于不支持 import-maps
的浏览器, 可以使用 es-module-shims 进行处理
import-maps
使用 json
的形式来定义浏览器中的全局模块:
<script type="importmap">
{
"imports": {
"react": "https://pdn.zijieapi.com/esm/bv/[email protected]",
"react-dom": "https://pdn.zijieapi.com/esm/bv/[email protected]"
}
}
</script>
这个时候,babel
编译 react
的产物就可以直接在浏览器内运行了
react-ts
相比于 react
,react-typescript
就是多了类型系统,所以上述 react
那一套还是适用的,只不过在编译的时候,需要告知 babel
需要额外编译 typescript
babelStandalone.transform(originContent, {
filename: 'file.tsx',
presets: ['react', 'typescript']
}).code
presets
的值,除了 react
,还需要加一个 typescript
,其他步骤就和 react
一样了
vue 2.x
vue
稍微麻烦点,因为vue
的 DSL
语法比较多,所以就有了 vue-loader
这个东西,提前将 vue
语法解析一遍,然后再将解析后的产物送给对应的 webpack
模块进行处理
vue-loader
没法直接在浏览器端使用,也没有提供能在浏览器端使用的版本,不过 vue-loader
是基于 vue-template-compiler,而这个东西就跟 node
和 webpack
没啥关系,且其具备预编译 vue2.x
单文件组件的能力
import { compile, parseComponent } from 'vue-template-compiler/browser'
parseComponent
用于解析 vue2.x
的单文件组件,将 template
、script
、style
分隔开
对于如下 vue2.x
文件
<template>
<div class="count" @click="addCount">click me {{count}}</div>
</template>
<script>
// import Vue from 'vue'
export default {
data() {
return {
count: 0
}
},
methods: {
addCount() {
this.count += 1
}
}
}
</script>
<style>
.count {
color: red;
}
</style>
经过 parseComponent
处理后输出
对于 script
的内容,其实就是一个正常的 js
对象,不需要任何处理浏览器就能执行;style
也就是正常的css
,但一般来说我们都不会直接写 css
的,都是写 less
、scss
这些预编译语言,不过前文已经写过了如何处理 css
预编译语言的情况,所以这也不是个问题,直接套用就是了
唯一需要处理的是 template
,因为目前还是处于 vue dsl
的状态,需要将其处理成浏览器可理解的 js
代码,compile
方法可以解决这个问题
上面的 template
内容经过 compile
处理将变成
with(this){return _c('div',{staticClass:"count",on:{"click":addCount}},[_v("click me "+_s(count))])}
如何将模板代码跟script
代码联系到一起呢?使用 vue
的 render
函数
export default {
data() {
return {
count: 0
}
},
methods: {
addCount() {
this.count += 1
}
},
render() {
// 将模板代码放到 render 函数内
with(this){return _c('div',{staticClass:"count",on:{"click":addCount}},[_v("click me "+_s(count))])}
}
}
如果你直接执行这段代码的话,vue
会给你报个 strict mode
错,因为 with
这种语法不符合 vue
的要求,所以我们还需要模板代码转成 strict mode
于是我又去找了一圈,找到了 vue-template-es2015-compiler,这个库可以将 vue
的模板代码转成符合 vue
规范的,但是这个库只有 CommonJS
的导出,没法在浏览器端使用,不过我又看了下发现这个库只是对 buble 的一个包装,buble 是可以直接被浏览器所执行的,所以那我就不要 vue-template-es2015-compiler
的包装了,直接引用 buble
import * as buble from 'vue-template-es2015-compiler/buble'
const strictModeCompile = (str: string) => {
return buble.transform(str, {
transforms: {
modules: false,
stripWith: true,
stripWithFunctional: false
},
objectAssign: 'Object.assign'
}).code
}
现在已经完成了对 vue
文件的编译工作,下一步需要将其与 vue
结合起来并最终在浏览器端执行,vue
是如何执行一个组件的呢?
new Vue({render:h=>h(component)}).$mount('#app');
其中 component
就是上面我们拼接好的 vue script
如果你查看码上掘金的 vue2.x
的编译产物,会发现码上掘金是这么引入组件的:
import component from 'data:text/javascript;base64,Ci8vIGltcG9ydCBWdWUgZnJvbSAndnVlJwpleHBvcnQgZGVmYXVsdCB7CiAgZGF0YSgpIHsKICAgIHJldHVybiB7CiAgICAgIGNvdW50OiAwCiAgICB9CiAgfSwKICBtZXRob2RzOiB7CiAgICBhZGRDb3VudCgpIHsKICAgICAgdGhpcy5jb3VudCArPSAxCiAgICB9CiAgfQp9Cg=='
import
的对象是一个链接地址,base64
可以当成一个链接地址来用,只需要将 vue
组件编译好的代码用 base64
编译一下,import
这个 base64
的地址就相当于是引入了这个组件,这么做的目的主要是为了兼容编译和组件使用的问题
vue 3.x
vue3.x
的组件编译和使用步骤跟vue2.x
差不多,只不过所需使用到的库不太一样
vue-template-compiler 不支持 vue3.x
的编译工作,转而由一个新的库来替代:vue/compiler-sfc
import { parse, compileTemplate } from 'vue/compiler-sfc'
parse
代替了 parseComponent
,且产物是符合 vue
规范的,所以不需要有转成 strict mode
这一步
const { descriptor, errors } = parse(originContent, { sourceMap: true })
vue3.x
对于 typescript
的支持度很好,所以必须得用上typescript
let babelScript = descriptor.script?.content || ''
try {
babelScript = babelStandalone.transform(babelScript, {
filename: 'app.ts',
presets: ['typescript']
}).code
} catch (err) {
console.error(err)
return
}
compileTemplate
则代替了 compile
方法
const genVue3Template = (template: string) => {
const { code } = compileTemplate({
id: 'app',
filename: 'App.vue',
source: template
})
return base64Encode(code)
}
最后,vue3.x
相比于 2.x
在执行组件的方法上也有点差别
import { createApp } from 'vue'
createApp(component).mount('#app')
总结
本文实现的思路是比较清晰的,无非是将编辑器内输入的内容,当成 html
、style
、script
交给 iframe
动态执行,遇到浏览器无法直接执行的代码就编译成能执行的,只不过细节还是比较多的,比如需要找到正确的编译库,还需要用正确的方式执行代码,资料查找和调试稍微耗点时间,总体上倒是没有多么大的技术难度