先放上文链接 如何制作一款在线编译器(1)
离上个文章发布又过去了一个月的时间,在这一个月的时间内,我又腾出了一些时间去完善编辑器的各个功能
现在的 JS-Encoder 长这样:
整体和以前差不多,主要是加了一个侧边栏
下面说一下这些日子里所实现的一些主要功能:
代码格式化
代码格式化是一个非常重要的功能,我们平时写代码的时候很难去注意程序的排版结构,很多换行,符号,空格都被忽略掉,导致代码可读性降低。
其实不光是自己写代码,现在是面向百度编程时代,上网 copy 代码也是常有的事,代码格式正常,看上去美观,一复制到自己的编辑器上就不知道哪儿是哪儿了,自己修改又浪费时间。所以,一个可以自动格式化的工具是非常必要的
那么如何实现自动格式化的功能呢?
我在 npm 上找到了一个叫做 js-beautify 的包,附上链接
js-beautify 可以格式化 html、css 和 js 代码,满足功能要求
比如我想格式化 html:
async function formatHtml(code) {
if (!loadFiles.get("beautifyHtml")) {
const beautifyHtml = await require("js-beautify").html;
loadFiles.set("beautifyHtml", beautifyHtml);
}
return loadFiles.get("beautifyHtml")(code, formatOptions);
}
只需要获取当前的 html 代码并作为参数传给 formatHtml,就可以了
在 formatHtml 中,loadFiles 的功能是用来判断 js-beautify 是否已载入的,因为只有需要格式化代码的时候才有必要载入它。
loadFiles:
class LoadFiles {
constructor() {
this.map = {};
}
set(k, v) {
this.map[k] = v;
}
get(k) {
return this.map[k];
}
}
const loadFiles = new LoadFiles();
同样的,格式化 css 和 js 的代码也用相同的方法
方法写好了之后就要绑定快捷键,我设置的格式化快捷键是 shift + alt + f,这也是我在 vscode 上格式化的快捷键
我是使用 vue-codemirror 工具来构建代码编辑器,于是要在 extraKeys 对象中定义快捷键,首先要引入格式化方法:
import * as format from "../utils/prettyFormat";
然后:
export default function(mode = "") {
const cmOptions = {
// 其他配置项省略
extraKeys: {
// 其他快捷键配置省略
"Shift-Alt-F": async cm => {
const code = cm.getValue();
let finCode = "";
switch (mode) {
case "HTML":
await format.formatHtml(code).then(res => {
finCode = res;
});
break;
case "CSS":
await format.formatCss(code).then(res => {
finCode = res;
});
break;
case "JavaScript":
await format.formatJavaScript(code).then(res => {
finCode = res;
});
break;
}
cm.setValue(finCode);
}
}
};
// 其他功能代码省略
}
mode 是在构建代码窗口的时候传入的参数,表示当前窗口使用的语言。
当快捷键被按下时,先判断焦点所在的代码窗使用的语言,再将代码传入格式化方法进行格式化,然后调用 cm.setValue 方法将窗口中的旧代码更新为格式化后的代码,大功告成:
Console
其实在上一文中,就已经实现了 console 的功能,但是只能够实现基本的输出功能,google 自带的 console 不光可以查看输出内容,还可以在里面写 js 代码调试
想要实现这个功能,首先就要在 console 窗口中添加一个可编辑的输入框,再尝试过 input,textarea 之后我选择给 div 加上 contenteditable="plaintext-only" 属性使其变成可编辑的 div,因为这个 div 可以根据内容自动改变高度
我们在谷歌控制台输入代码的时候,会出现返回值:
我们知道 push 方法执行后返回的是数组的长度,所以当我们向一个含有一个元素的数组中 push 元素的时候返回的就是 2
实现这个效果其实很简单,给 div 绑定一个监听回车键按下的函数:
<div
@keyup.enter="sendCommand"
class="input-text"
contenteditable="plaintext-only"
spellcheck="false"
type="text"
></div>
sendCommand 函数:
methods:{
// 其他方法省略
sendCommand(e){
// 获取可编辑div文本
let text = e.srcElement.innerText
// 使用正则去掉文本前后的换行
const code = text.replace(/^\n+|\n+$/g, '')
// 其他代码省略
}
}
sendCommand 的一些代码被我省略掉了,因为这些省略的代码是根据我的项目结构来的,而不是必要的,其作用是将 code 传给一个包装函数:
function exeCode(code) {
return (
`\ntry {\n` +
`console.log('${code}')\n` +
`var r = eval('${code}')\n` +
`console.log(r)\n` +
`} catch(e) {\n` +
`console.log(e)\n` +
`}\n`
);
}
使用 try...catch 来捕获可能出现的错误,定义一个变量 r,将使用 eval 方法执行 code 后的值赋给 r,这样 r 就是 code 执行后的返回值
注意:这里的 console 是重写过的,详情请看如何制作一款在线编译器(1)
举个例子,当我们在可编辑 div 中打上 1+1 并按下回车,iframe 的 script 标签中会插入如下 js :
try {
console.log("1+1"); // '1+1'
var r = eval("1+1"); // 2
console.log(r); // 2
} catch (e) {
console.log(e);
}
效果虽然没有谷歌控制台的好,但至少有是吧 😄
命令记忆
接下来实现控制台的记忆功能,也就是按上下方向键可以查看命令历史,如图:
首先给可编辑 div 绑定一个 input 事件监听键盘输入,再绑定 keydown.down 和 keydown.up 事件:
<div
@input="change"
@keydown.down="switchNextInfo"
@keydown.up="switchLastInfo"
@keyup.enter="sendCommand"
class="input-text"
contenteditable="plaintext-only"
spellcheck="false"
type="text"
></div>
在 data 中定义三个变量:
export default {
data() {
return {
commandHistory: [], // 一个用来记录历史命令的数组
historyIndex: 0, // 当前指令所在历史命令数组中的下标
value: "" // 当前可编辑div内的文本
};
}
// 省略其他配置
};
因为可编辑 div 内不能使用 v-model 的原因,需要自己实现对内容的监听:
export default {
methods: {
change(e) {
// 由于我并没有在input事件返回的event对象上找到keycode的信息,所以要通过其他方法来判断回车键是否被按下
// data属性存储着当前插入的字符
const data = e.data;
// inputType属性表示的是导致文本变化的行为
const inputType = e.inputType;
// 当data值为null,并且inputType值为insertLineBreak或者insertText,说明按下了回车键
if (data === null) {
if (inputType === "insertLineBreak" || inputType === "insertText") {
return;
}
}
// 将value设置为div文本值
this.value = e.srcElement.innerText;
}
},
watch: {
value(newVal) {
const len = this.commandHistory.length;
// 当value改变,将命令历史的最后一个元素值改成value,也就是div内的文本
this.commandHistory.splice(len - 1, 1, this.value);
}
}
// 省略其他配置
};
当上箭头按下时,要先检测光标位置是否处于 div 文本的第一行,由于暂时没有找到获取行数的方法,我检测的是光标是否在文本的开头处:
methods: {
switchLastInfo(e) {
// 获取光标位置
const position = this.getCursorPosition()
// 检测光标起始位置和光标结束位置是否都为0
if (position.start !== 0 && position.end !== 0) return
// 如果光标在文本开头处,则找到上一个历史命令
const history = this.getCommandHistory(-1)
// 如果上一个历史命令存在,就将div文本设置为上一个历史命令
if (history) e.srcElement.innerText = history
}
// 省略其他方法
}
getCursorPosition 用于获取光标位置:
methods:{
getCursorPosition() {
// 调用window.getSelection().getRangeAt(0)获取光标信息
const range = window.getSelection().getRangeAt(0)
// range.startOffset属性为光标的起始位置
const start = range.startOffset
// range.endOffset属性为光标的结束位置
const end = range.endOffset
return {
start,
end
}
}
// 省略其他方法
}
大多数情况下光标的起始和结束位置都是一样的,只有在选中文本的时候才会不同
getCommandHistory 方法用于获取历史命令:
methods: {
getCommandHistory(num) {
// 缓存历史命令列表
const list = this.commandHistory
const newIndex = this.historyIndex + num + 1
const history = list[newIndex]
// 如果命令存在包括命令为空字符串的情况,改变当前指令所在历史命令数组中的下标
if (history || history === '') this.historyIndex += num
return history
}
// 省略其他方法
}
switchNextInfo 和 switchLastInfo 作用相同,只是 switchNextInfo 是在按下方向键的时候触发:
methods: {
switchNextInfo(e) {
const position = this.getCursorPosition()
const len = e.srcElement.innerText.length
// 检测光标是否在文本尾部
if (position.start !== len && position.end !== len) return
// 光标在文本尾部则获取下一个历史命令
const history = this.getCommandHistory(1)
// 如果命令存在包括命令为空字符串的情况,改变当前指令所在历史命令数组中的下标
if (history || history === '') e.srcElement.innerText = history
}
// 省略其他方法
}
当光标在文本尾部的时候,光标起始和结束位置的值和 div 内文本(字符串)的长度(length)是一样的
完成效果:
console 的功能暂时就是这样了,还存在着很多缺陷,以后会慢慢完善的
颜色转换
侧边栏的 color 选项中可以进行颜色转换和复制
颜色转换的方法也很简单:
function switchRGB(color) {
if (!color) return;
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
return {
r: parseInt(result[1], 16) + "",
g: parseInt(result[2], 16) + "",
b: parseInt(result[3], 16) + ""
};
}
function switchHEX(color) {
if (!(color.r && color.g && color.b)) return;
const r = parseInt(color.r);
const g = parseInt(color.g);
const b = parseInt(color.b);
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
export { switchRGB, switchHEX };
由于 window.clipboardData 接口只在 IE 浏览器上实现,在其他浏览器上就要通过 input 元素来实现该功能:
// 创建input元素
const input = document.createElement("input");
// 将input的value值设置为当前色块的HEX值
input.value = hex;
// 将input插入到body中
document.body.appendChild(input);
// 选取input内容
input.select();
// 执行copy命令
document.execCommand("Copy");
// 最后移除input元素
document.body.removeChild(input);
这样就实现了将 `HEX 颜色复制到剪贴板的功能
文件上传
文件上传功能可以将本地的特定后缀名的文件导入到编辑器中,可导入的文件类型的集合用一个数组保存起来
const limitType = [
"html",
"css",
"js",
"md",
"sass",
"scss",
"less",
"styl",
"ts",
"coffee"
];
其实只要使用 FileReader 类将文件内容取出,再赋给编辑窗口就好了:
async function readFile(file) {
const reader = new FileReader()
// 使用utf-8编码读取文件
reader.readAsText(file, 'UTF-8')
return new Promise((resolve, reject) => {
reader.onload = e => {
// 获取文本内容
const fileString = e.target.result
// 省略其他代码
}
}
}
但如果是 html 文件,情况可就不同了,因为在 html 文件中既可以写 css 也可以写 js,所以在获取文本内容之后还要使用正则表达式截取
截取 body 标签中的 html 代码:
// 获取body标签内的内容
const result = /<body[^>]*>([\s\S]*)<\/body>/.exec(content);
let finalCode = "";
// 如果成功会返回一个长度为2的数组,第二个元素就是我们要得到的内容
if (result && result.length === 2) finalCode = result[1];
// 将body内的script标签及其内容替换成空字符串
return finCode.replace(
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
""
);
获取 style 标签内容:
const result = /<style>(([\s\S])*?)<\/style>/g.exec(content);
let finCode = "";
if (result && result.length >= 2) finCode = result[1];
同时还要将 link 内的外部链接取出保存到一个数组中:
// 获取所有link标签
const linkList = content.match(/<link.*?(?:>|\/>)/gi);
const link = [];
if (linkList) {
for (let i = 0, content; (content = linkList[i++]); ) {
link.push(splitLink(content));
}
}
// splitLink代码
// 获取link标签内的href属性值
const result = content.match(/<link .*?href=\"(.+?)\"/);
if (result && result.length === 2) return result[1];
获取 script 标签内容:
const codeList = content.match(/<script>([\s\S]+?)<\/script>/gi);
let finCode = "";
if (codeList) {
for (let i = 0, content; (content = codeList[i++]); ) {
finCode += splitScriptContent(content) + "\n";
}
}
// splitScriptContent代码
const result = /<script>([\s\S]+?)<\/script>/gi.exec(content);
if (result && result.length === 2) return result[1];
还要获取 script 标签引入的外部链接
const scriptList = content.match(/<script.*?(?:>|\/>)\<\/script\>/gi);
const CDN = [];
if (scriptList) {
for (let i = 0, content; (content = scriptList[i++]); ) {
CDN.push(splitScriptSrc(content));
}
}
// splitScriptSrc代码
const result = content.match(/<script .*?src=\"(.+?)\"/);
if (result && result.length === 2) return result[1];
在做完这些操作之后,得到的代码会赋给相应的窗口作为内容,外部链接也会保存在设置中
这就是这段时间内我在 JS-Encoder 上遇到的几个重要问题和解决方法,当然了,无论是代码还是解决方法可能都是有缺陷的,如果大佬们发现了问题请及时告诉我,我会尽快修改
最后,放上项目的 github 链接,如果你们对这个项目感兴趣,不妨点一个 star 来鼓励鼓励我哦