距离上次在掘金写作已经过去了很长时间,这段时间里,我也没有开发什么有意思的项目。最近终于有了些空闲时间,我决定学习一下NuxtJs 3。虽然它已经发布有一段时间了,但因为工作中没有机会使用,我只能在业余时间去研究。
每次学习新技术时,如果只是死记硬背,往往难以真正掌握。因此,我决定和以往一样,通过制作一个小Demo来加深理解。这次我选择了一个纯前端的项目,想要实现一个在线代码编辑器,也就是常说的Playground
。这种工具在许多网站上都可以见到,它们背后的实现原理一直让我感到好奇。
比如,掘金之前推出的码上掘金,我之前也使用过,觉得非常有趣。再比如Codepen,它们都允许用户在线编写和展示代码片段,还可以在上面找到许多有创意、有趣的案例。通过实现一个类似的在线代码编辑器,就可以打造一个代码片段网站了,就可以收藏很多有趣的炫酷效果,在以后工作中可以随时找到以前的和平时发现的炫酷代码片段拿来使用,就会很方便,为此,来做了这个Demo。
在线体验
老规矩,先放成品,着急的同学可以点击穿越了,点击跳转 ----> Cooper在线编辑器
项目已开源 开源地址 点击跳转 ----> online-snippet-editor
上面就是一个完成的场景了,我们将由此来考虑要完成这样一个编辑器需要哪些知识点和了解哪些东西吧,这些东西都是纯js的知识,和你使用什么框架无关,所以我们并不纠结使用什么技术栈,项目搭建这些知识,直接进入设计界面。
前提纲要
作为这样一个编辑器,我们需要掌握的知识,需要实现的内容,包括且不限于
- 实现一个可随意拖拽的页面布局
- 了解JS的沙箱环境,什么是沙箱,如何使用
- 如何在web环境编译
less
,sass
等预编译语言。 - 如何动态引入
css
,script
- 如何格式化代码,格式化
html
,css
,javascript
等等这些语言 - 如何实时编译将其渲染到页面上。
- 如何收集编辑器的日志,并将此呈现出来
- 纯前端实现的在线编辑器的弊端。
分析对比,实现布局
我们就此上面的内容开始整个项目,为了更有对比性,我们参考 码上掘金,下图是掘金的码上掘金编辑器,
一个编辑器基础包含如下内容,html编辑区域
,css编辑区域
,javascript编辑区域
,代码预览区域
,可以自定义页面布局形式
,可以自由拖拽各个区域
,可以使用不同的html类型
,不同的css语言类型
,不同的js可选类型
,动态head头配置
,动态link,script脚本插入
,等这些内容,这是一些基于知识,只涉及到布局,所以我们可以根据参考完成这样的一个布局,至此,我们完成了最为基础的部分。
!
了解JS的沙箱环境
在我们编辑了代码之后,我们需要一个沙箱环境来执行这个代码,代码里可能包含了:html
,css
,js
,links
,scrits
这些内容,我们需要将其组织并且在沙箱环境中执行并且渲染出来,在此之前我们可以稍微了解一些沙箱,对于浏览器而言,每一个Tab标签就是一个沙箱,他们可以相互独立运行,互不干扰,即使需要通信,也只能在同源的场景写通过类似,Postmessage进行通信。网页 web 代码内容必须通过 IPC 通道才能与浏览器内核进程通信,在 JavaScript 中,沙箱(sandbox)是一个安全机制,用于隔离运行代码。
实现沙箱的方式有多种,例如,with + new Function
,web worker
,Iframe
等等,但是由于我们需要的是将代码渲染到浏览器中,所以常见的方法是使用Iframe这也是目前比较主流的做法。
初次之外,如果你想实现的代码编辑器只包含html
,css
代码而并不需要使用到javascript
的情况下,我们还可以选择Shadow DOM进行实现,相对而言,Shadow DOM的实现较为简单,可以用来编写不同的纯样式的代码片段,部分网站就是如此实现的。
Shadow Dom 实现一个简易编辑器
我们可以用很少的代码实现一个Shadow Dom的在线编辑器
<template>
<div ref="shadowRootContainer"></div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue';
const props = defineProps({
htmlContent: {
type: String,
required: true
},
cssContent: {
type: String,
required: true
}
});
const shadowRootContainer = ref(null);
const attachShadowRoot = async () => {
const container = shadowRootContainer.value;
let shadowRoot = container.shadowRoot;
if (!shadowRoot) {
shadowRoot = container.attachShadow({ mode: 'open' });
} else {
shadowRoot.innerHTML = '';
}
const style = document.createElement('style');
style.type = 'text/css';
style.appendChild(document.createTextNode(props.cssContent));
shadowRoot.appendChild(style);
const content = document.createElement('div');
content.innerHTML = props.htmlContent;
shadowRoot.appendChild(content);
};
onMounted(() => attachShadowRoot());
watch(
() => [props.htmlContent, props.cssContent, props.tailwindCdn],
async (newValues, oldValues) => {
console.log('newValues: ', newValues);
if (newValues.some((value, index) => value !== oldValues[index])) {
await attachShadowRoot();
}
},
{ deep: true }
);
</script>
上述代码,就是一个vue组件,传入编辑的 html, css的代码就可以实时渲染在页面中,他只需要在指定Dom上创建一个Shadow Dom元素 container.attachShadow({ mode: ‘open’ }),随后将代码挂载给他即可。
由于Shadow Dom的 html,css类似沙箱隔离,但是他的脚本并不隔离,在 Shadow DOM 内部执行的脚本仍然运行在全局上下文中,可以访问和操作整个页面的 window 和 document 对象,外部脚本可以通过 API 访问和操纵 Shadow DOM 内的内容。所以如果要做一个相对灵活的编辑器则显得不够安全和灵活,如果没有js的需求,也不失为一种好的方法。
如何在浏览器环境编译Less或者Sass语言
在编辑器中我们尝尝是可以选择css的预编译语言的,我们可以编写Less,Sass的语法,但是最终渲染浏览器环境只认识css,所以我们需要将其在渲染前编译为css文件,但是大多数场景,我们都是基于工程化在本地开发,并没有在web环境去编译它们的需求,但是它们也为web环境提供了API。
编译Less语法
在less中,他的npm包中就提供了编译方法。
import less from 'less';
import postcss from 'postcss';
import discardComments from 'postcss-discard-comments';
import discardEmpty from 'postcss-discard-empty';
import discardDuplicates from 'postcss-discard-duplicates';
export function convertLessToCss(lessCode: string): Promise<string> {
if (typeof lessCode !== 'string') {
console.error('The LESS code is not a string:', lessCode);
return Promise.reject(new TypeError('The LESS code must be a string'));
}
return new Promise((resolve, reject) => {
less.render(lessCode, (err: any, output: any) => {
if (err) {
reject(err);
} else {
postcss([discardComments, discardEmpty, discardDuplicates])
.process(output.css, { from: undefined })
.then(result => {
resolve(result.css);
})
.catch(postcssErr => {
console.log('postcssErr: ', postcssErr);
reject(postcssErr);
});
}
});
});
}
在线编译Sass语法
npm包中我并未找到编译的语法,但是通过阅读文档Sass in the Browser,找到了一份cdn的文件,引入 https://cdnjs.cloudflare.com/ajax/libs/sass.js/0.11.1/sass.sync.min.js
这个编译文件,我们即可在web环境编译Sass了
export async function convertSassToCss(scssCode: string): Promise<string> {
if (typeof scssCode !== 'string') {
console.error('The SCSS code is not a string:', scssCode);
return Promise.reject(new TypeError('The SCSS code must be a string'));
}
return new Promise(async (resolve, reject) => {
if (!window?.Sass) return resolve("");
window.Sass.compile(scssCode, (result: any) => {
if (result.status !== 0) {
reject(new Error(result.formatted));
} else {
postcss([discardComments, discardEmpty, discardDuplicates])
.process(result.text, { from: undefined })
.then(postcssResult => {
resolve(postcssResult.css);
})
.catch(postcssErr => {
console.error('postcssErr: ', postcssErr);
reject(postcssErr);
});
}
});
});
}
在编译的过程中,我们引入了一些对应的Postcss插件,就是对于css的一些扩展补充,这个可以根据个人需求添加更多。
如何将内容渲染到网站上?
通过查看大多数的在线编辑器网站,发现都使用的是Iframe实现,这种方式也是目前最为主流的方式,但是即使都是Iframe也有不同的使用方法。
- 通过 src 加载远程地址,在后端开启不同的沙箱实现编译
- srcdoc 在前端直接传入html文档进行渲染
两者各有优劣,第一种的优势包括动态加载 支持跨域 缓存利用 后端可以有不同的沙箱环境,支持更多种类的语言,当然也会有一些劣势,依赖网络 安全限制 性能开销等问题。
我们要开发纯前端的所以使用了第二种,这种方式我们没有一个别的沙箱环境去执行一些比如Nodejs环境需要的代码,他也有一些弊端,不适合加载复杂的或动态生成的内容,尤其是需要频繁更新的页面,不支持缓存:每次都需要重新渲染内容,无法利用浏览器的缓存机制,如果需要嵌入大量内容,管理和维护变得复杂, 所以支撑不了大型项目,但是对于我们开发代码片段是完全没问题的。
所以我们的渲染思路非常简单,将我们编写的代码组织为Html文档然后传递给srcdoc进行渲染即可,所以,在了解这些之后,我们来编译我们的代码为html文档。
编译code为Html文档
在上述编辑器页面,我们可以获得的内容有哪些还记得么?
- html代码
- css代码
- javascript代码
- css代码类型
- 自定义head头
- 动态添加的link链接
- 动态增加的scripts脚本
- 是否引入resetcss
可以看到我们顶部还有一个配置项可以用于配置代码以外的内容
所以现在我们获得了,htmlCode
,cssCode
,jsCode
,cssType
,defaultCss
,links
,scripts
等内容,我们需要将其串联起来并且拼接为脚本。
我们的思路是返回一个Html文档,包含了其内容,在此之前,如果css类型是less或者sass我们需要对其进行编译并且复制给css,同时,我们提供了选项resetcss和normalizeCss,如果勾选了这两个的情况下,我们也需要在他的style标签插入这些内容。同时还需要将配置的links,script进行插入,下面就是一个完整的编译代码方法。
let cacheSuccessfulCss = ""; // 如果本次编译有问题说明就使用上次的css等待本次css编写完成
export async function compilerIframeCode(options: RunIframeParams, preSettings: PreSettings, previewOpts: PreviewSettings) {
try {
const { html = '', css = '', js = "" } = options;
const {
htmlSettings: { headSettings = '', htmlClass = '', bodyClass = '', htmlType = 'html' },
styleSettings: { links: preLinks = [], styleType = "css", defaultCss = 0 },
scriptSettings: { scripts: preScripts = [] },
} = preSettings;
let preCss = `<style></style>`;
let preViewCss = `<style></style>`;
if (defaultCss === 1) {
preCss = normalizeCss
}
if (defaultCss === 2) {
preCss = resetCss
}
if (previewOpts.preview) {
preViewCss = compilerPreviewCss(previewOpts)
}
// 过滤掉空字符串的链接和脚本
const filteredScripts = preScripts.filter(src => src.trim() !== '');
const filteredLinks = preLinks.filter(href => href.trim() !== '');
/* 对于特殊类型的css进行一次解析 普通css也可以通过less解析 */
let formatCss = css
if (['less', 'css'].includes(styleType)) {
try {
formatCss = await convertLessToCss(css)
cacheSuccessfulCss = formatCss
} catch (error) {
console.log('convertLessToCss error: ', error);
formatCss = cacheSuccessfulCss
}
}
if (['sass'].includes(styleType)) {
try {
formatCss = await convertSassToCss(css)
cacheSuccessfulCss = formatCss
} catch (error) {
console.log('convertSassToCss error: ', error);
formatCss = cacheSuccessfulCss
}
}
// 将scripts数组转换成多个script标签,移除async属性
const scriptsTags = filteredScripts
.map(src => `<script src="${src}"></script>`)
.join('\n');
// 将links数组转换成多个link标签
const linksTags = filteredLinks
.map(href => `<link rel="stylesheet" href="${href}">`)
.join('\n');
/* 将import type=module语句单独提取放入顶层 */
const importStatements: any[] = [];
const otherJsStatements = js.split('\n').filter(line => {
if (line.startsWith('import ')) {
importStatements.push(line);
return false;
}
return true;
}).join('\n');
const code = `
<html class="${htmlClass}" style="height:100%">
<head>
<script>console.warn = () => {};</script>
${headSettings}
${`${defaultCss !== 0 ? preCss : ''}`}
${`${previewOpts.preview ? preViewCss : ''}`}
<style> ${css} </style>
${linksTags}
${scriptsTags}
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('a[href="#"]').forEach(function(anchor) {
anchor.addEventListener('click', function(event) {
event.preventDefault();
});
});
});
</script>
</head>
<body class="${bodyClass}">
${html}
<script type="module">
${importStatements.join('\n')}
</script>
<script>
${otherJsStatements}
</script>
</body>
</html>
`;
return code;
} catch (error) {
console.log('compilerIframeCode error: ', error);
return ""
}
}
此时,我们再将其传递给srcdoc即可完成渲染,当然,我们还可以做一些扩展优化一些体验。
功能扩展优化
为了我们的编写更加流畅,我们可以对一些地方进行优化
格式化代码
我们需要提供格式化代码功能,想要在web环境格式化代码,我们可以使用prettier即可,大多数场景,我们是在本地开发来格式化代码的,想要在web端格式化,只需要找到对应的api即可。
import prettier from 'prettier/standalone';
import * as htmlParser from 'prettier/plugins/html.js';
import * as babelParser from 'prettier/plugins/babel.js'; // 用于解析 JavaScript
import * as cssParser from 'prettier/plugins/postcss.js'; // 用于解析 CSS
import * as esTreeParser from 'prettier/plugins/estree.js'; // 用于解析 TypeScript
import * as postcssParser from 'prettier/plugins/postcss.js'; // 用于解析 CSS
// https://prettier.io/docs/en/options
export async function formatCode(code: string, language: string) {
let parser = '';
switch (language) {
case 'html':
parser = 'html';
break;
case 'js':
case 'javascript':
parser = 'babel';
break;
case 'css':
case 'vue':
parser = 'css';
break;
default:
throw new Error('Unsupported language');
}
const data = await prettier.format(code, {
parser: parser,
plugins: [htmlParser, babelParser, esTreeParser, cssParser, postcssParser],
tabWidth: 2,
htmlWhitespaceSensitivity: "ignore"
});
return data;
}
这样就可以格式化我们的代码了,可以在你需要的地方执行即可。
错误提醒
我们的js执行过程中,或者编码到一半的时候都可能有错误,想要将这个错误展示出来,我们只需要捕获错误将其展示出来即可
<script>
try {
${otherJsStatements}
} catch (error) {
document.body.innerHTML += '<pre style="color: red;">' + error.toString() + '</pre>';
}
</script>
这就是简单思路,如果你想展示的更为详细,也可以写一个插件,通过script引入将错误传递给插件,那么就可以扩展更为详细的错误了。
集成Tailwindcss
现在的原子话css也较为广泛了,我们也可以将其集成进去,只需要增加一个配置,并且引入官方的cdn文件即可在html文档中直接使用Tailwindcss了,也是非常方便,可以实现很多有趣的效果。
/* 对于tailwindcss 类型的 注入脚本 */
if (htmlType === 'html-tailwindcss') {
filteredScripts.push('https://cdn.tailwindcss.com')
}
很多代码片段也是基于其实现的,如果想要加入别的也是类似的原理。
console控制台
类比码上掘金,会有自己的控制台,我们可以如何做呢,思路很简单,我们可以拦截原生的console方法,然后在中间拿到参数通过Postmessage将参数传递给外层,在外面我们就可以拿到iframe打印的日志了,此时我们对日志展示即可,我们只需要封装这样一个类
try {
class ProxyConsole {
constructor() {
this.methods = [
'debug',
'clear',
'error',
'info',
'log',
'warn',
'dir',
'props',
'group',
'groupEnd',
'dirxml',
'table',
'trace',
'assert',
'count',
'markTimeline',
'profile',
'profileEnd',
'time',
'timeEnd',
'timeStamp',
'groupCollapsed'
];
this.methods.forEach((method) => {
let originMethod = console[method]; // 保存原始方法
this[method] = (...args) => {
const serializedArgs = args.map(arg => {
try {
return JSON.stringify(arg);
} catch (e) {
return \`[Unserializable] \${Object.prototype.toString.call(arg)}\`;
}
});
window.parent.postMessage({
type: 'console',
method,
data: serializedArgs
});
originMethod.apply(window.console, args);
};
});
}
}
window.console = new ProxyConsole();
} catch (error) {
console.log('proxy console err', error);
}
只需要将这段代码注入到iframe容器即可可以src引入也可以直接插入标签,此时内部的打印,外侧都可以收到了,至于自定义控制台的显示就可以随意发挥了。
End
纯前端实现的在线编辑器依然有很多不足与缺陷,在前面的地方也提到,我们的场景比较简单,我只想做一个炫酷效果的代码片段收藏夹,想开发一个代码片段网站,在以后得编码过程中需要使用到的时候,可以快速找到这些有趣的动画,对于我而已是已经ok了,但是如果要考虑到更多语言,加上框架,三方包等,虽然现在的浏览器已经支持EsModule让我们可以在原生js直接引入一些包了,但是很多缺陷依然无法弥补,如果后续还有类似场景,会考虑介入后端来完成,类似码上掘金这类可以选择非前端语言的模板,其本质都是后端构建相应的沙盒环境实时计算出结果凸出给前端,其Iframe也是加载了src的模板在后端进行编译处理的。涉及到在线编辑器的真实应用场景会比这复杂更多,如果要做到实用全面是离不开后端配合的,如果是场景简单,那么纯前端的编辑器依然有很多的应用空间。
炫酷代码片段网站推荐
学一个技术点,做一个Demo案例,这次的案例是Cooper-cool炫酷代码片段仓库,基于此开发了一个代码片段仓库,后续可以扩展开发一下,做一个可以收藏有趣动画效果的网站,项目已开源至online-snippet-editor,如果你觉得有帮助,请帮我 Start吧!