码上掘金很好用,今天高低得从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