Web移动端在线IDE(online-editor)

  目录

web在线编辑器,适配移动端

常用的在线编辑器有:
codesandbox
codepen
stackblitz
playcode
jsfiddle

IDE组成

在线编辑器由编辑区,预览区,打印输出区三部分组成。
对应的组件分别是:Editor,Preview,Console

1
2
3
4
5
6
7
8
9
10
11
// Layout.vue
<template>
<div class="layout-wrap">
<Header></Header>
<div class="content-wrap">
<Editor></Editor>
<Preview></Preview>
<Console></Console>
</div>
</div>
</template>

Editor

编辑区就是输入代码的地方,前端常用的代码编辑工具是vsCodevsCode有一个在线库monaco-editor,这个库是微软专门为浏览器开发的一个在线vsCode,功能与在本地使用的vsCode基本一样,所以,在编辑功能这里使用这个工具库。
monaco-editor的具体使用可以参考官网monaco-editor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// Editor.vue
<template>
<div class="editor-wrap">
<div v-if="$store.languageType===1">
<div ref="refHtml" class="html-wrap">html</div>
<div ref="refJs" class="js-wrap">js</div>
<div ref="refCss" class="css-wrap">css</div>
</div>
<div v-else ref="refVue" class="vue-wrap">vue</div>
</div>
</template>

<script setup>
import { onMounted, ref, getCurrentInstance, watch, nextTick } from 'vue';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'
import { languageMap } from '@/config/constants'

const { proxy } = getCurrentInstance();
const refHtml = ref(null);
const refJs = ref(null);
const refCss = ref(null);
const refVue = ref(null);
const editorArr = [];
const createEditor = async (el, language) => {
const editor = monaco.editor.create(el, {
model: null,
minimap: {
enabled: false // 关闭小地图
},
wordWrap: 'on', // 代码超出换行
theme: 'vs-dark', // 主题
fontSize: 14,
fontFamily: 'Microsoft YaHei',
contextmenu: false, // 不显示右键菜单
fixedOverflowWidgets: true, // 让语法提示层能溢出容器
readOnly: false
})
// 设置文档内容
updateDoc(editor, proxy.$store[languageMap[language]], language)
// 支持textMate语法解析
// wire(language, editor)
// 监听编辑事件
editor.onDidChangeModelContent(() => {
// console.log('code-change', editor.getValue())
proxy.$store[languageMap[language]] = editor.getValue();
})
// 监听失焦事件
editor.onDidBlurEditorText(() => {
// console.log('blur', editor.getValue())
})

// editor添加入数组保存
editorArr.push(editor);
}

// 更新编辑器文档模型
const updateDoc = (editor, code, language) => {
language = language==='vue'?'html':language;
let oldModel = editor.getModel();
let newModel = monaco.editor.createModel(code, language);
editor.setModel(newModel);
if (oldModel) {
oldModel.dispose();
}
}

onMounted(()=> {
});

watch(()=> proxy.$store.languageType, async ()=> {
if(proxy.$store.languageType===1) {
await nextTick(()=> {
createEditor(refHtml.value, 'html');
createEditor(refJs.value, 'javascript');
createEditor(refCss.value, 'css');
});
}else if(proxy.$store.languageType===2) {
await nextTick(()=> {
createEditor(refVue.value, 'vue');
});
}
}, { immediate: true });

</script>

<style lang="less" scoped>
.editor-wrap {
.html-wrap, .js-wrap, .css-wrap, .vue-wrap {
min-height: 30vh;
border: 1px solid #eeeeee;
margin: 5px;
}
.vue-wrap {
min-height: 50vh;
}
}
</style>

Preview

预览区域应该与主页面隔绝,避免预览的代码污染主体页面。所以需要弄一个沙箱环境,这里选择最简单的iframe来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Preview.vue
<template>
<div class="preview-wrap">
<p>预览</p>
<iframe v-if="$store.iframeShow" :srcdoc="$store.docContent" :key="$store.docContent"></iframe>
</div>
</template>

<script setup>

</script>

<style lang="less" scoped>
.preview-wrap {
height: 50vh;
border: 1px solid #eeeeee;
margin: 5px;
iframe {
width: 100%;
height: 100%;
border: none;
}
}
</style>

iframesrcdoc内容是点击运行按钮生成的,这段代码在Header组件内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Header.vue
// vanilla代码生成
const createHtml = (htmlStr='', jsStr='', cssStr='')=> {
if(proxy.$store.languageType===1) {
let head = `
<title>预览<\/title>
<style type="text/css">
${cssStr}
<\/style>
`;
let jsContent = `
<script>
${jsStr}
<\/script>
`;
let body = `
${htmlStr}
${jsContent}
`;
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
${head}
<\/head>
<body>
<script src="/onlineEditor/lib/console.js"><\/script>
${body}
<\/body>
<\/html>
`;
}else if(proxy.$store.languageType===2) {

}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// vue代码生成
const createVue = (sfcStr)=> {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" \/>
<title>预览<\/title>
<\/head>
<body>
<script src="/onlineEditor/lib/vue.runtime.global.prod.js"><\/script>
<script src="/onlineEditor/lib/vue3-sfc-loader.js"><\/script>
<script src="/onlineEditor/lib/console.js"><\/script>
<script>
/* <!-- */
const config = {
files: {
'\/main.vue': \`${sfcStr}\`,
}
};
/* --> */

const options = {
devMode: true,
moduleCache: {
vue: Vue,
},
async getFile(url) {

if ( config.files[url] )
return config.files[url];

const res = await fetch(url);
if ( !res.ok )
throw Object.assign(new Error(res.statusText + ' ' + url), { res });
return {
getContentData: asBinary => asBinary ? res.arrayBuffer() : res.text(),
}
},

addStyle(textContent) {

const style = Object.assign(document.createElement('style'), { textContent });
const ref = document.head.getElementsByTagName('style')[0] || null;
document.head.insertBefore(style, ref);
},

handleModule: async function (type, getContentData, path, options) {

switch (type) {
case '.png':
return getContentData(true);
}
},

log(type, ...args) {

console[type](...args);
}
}

const app = Vue.createApp(Vue.defineAsyncComponent(() => window['vue3-sfc-loader'].loadModule('/main.vue', options)))
app.mount(document.body);

<\/script>
<\/body>
<\/html>`;
}

Console

打印输出,首先要把预览iframe里的console的所有方法拦截,之后用window.parent.postMessage方法,把信息发送给主页面。当然,主页面也需要注册message事件。

1
2
3
4
5
6
// App.vue
window.addEventListener('message', (ev) => {
if(ev.data.type === 'console') {
proxy.$store.consoleContent.push(ev.data);
}
})

iframe中的拦截console代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// public/lib/console.js
function ProxyConsole() {};
// 拦截console的所有方法
[
'debug',
'clear',
'error',
'info',
'log',
'warn',
'dir',
'props',
'group',
'groupEnd',
'dirxml',
'table',
'trace',
'assert',
'count',
'markTimeline',
'profile',
'profileEnd',
'time',
'timeEnd',
'timeStamp',
'groupCollapsed'
].forEach((method) => {
let originMethod = console[method]
// 设置原型方法
ProxyConsole.prototype[method] = function (...args) {
// 发送信息给父窗口
window.parent.postMessage({
type: 'console',
method,
data: any2str(args)
})
// 调用原始方法
originMethod.apply(ProxyConsole, args)
}
})
// 覆盖原console对象
window.console = new ProxyConsole()

// 处理console的参数转字符串
const any2str = (arr)=> {
let str = '';
arr.forEach(item=> {
str += change2str(item) + ' ';
});

return str;
}

const type = arg=> {
return Object.prototype.toString.call(arg).slice(7, -1);
}

const change2str = content=> {
let contentType = type(content)
switch (contentType) {
case 'boolean': // 布尔值
content = content ? 'true' : 'false'
break;
case 'null': // null
content = 'null'
break;
case 'undefined': // undefined
content = 'undefined'
break;
case 'symbol': // Symbol,Symbol不能直接通过postMessage进行传递,会报错,需要转成字符串
content = content.toString()
break;
default:
break;
}
return content;
}

总结

界面截图:
img

本实例使用vue3+webpack开发,monaco-editorvite中的兼容性不是很好,所以选择webpack
本实例代码