码上掘金很好用,今天高低得从0到1手撸一个
编辑器
看了下 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
自定义智能提示和高亮,但很明显这个工作量还是比较高的
虽然这个文件是针对 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
通信的方式很多,本文选择 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
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/react@17.0.2'
import ReactDOM from 'https://pdn.zijieapi.com/esm/bv/react-dom@17.0.2'
<script>
改成上面这种,浏览器就能正常运行了
但如果就想像在 webpack
里那样引用也是可以的,import-maps
就是为了解决浏览器中的全局模块而出现的,目前只有基于 Chromium
支持这一特性,对于不支持 import-maps
的浏览器, 可以使用 es-module-shims 进行处理
import-maps
使用 json
的形式来定义浏览器中的全局模块:
<script type="importmap">
{
"imports": {
"react": "https://pdn.zijieapi.com/esm/bv/react@17.0.2",
"react-dom": "https://pdn.zijieapi.com/esm/bv/react-dom@17.0.2"
}
}
</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
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
动态执行,遇到浏览器无法直接执行的代码就编译成能执行的,只不过细节还是比较多的,比如需要找到正确的编译库,还需要用正确的方式执行代码,资料查找和调试稍微耗点时间,总体上倒是没有多么大的技术难度
转载自:https://juejin.cn/post/7126783860917927972