如何制作一款在线编译器(2)

490 阅读6分钟

先放上文链接 如何制作一款在线编译器(1)

离上个文章发布又过去了一个月的时间,在这一个月的时间内,我又腾出了一些时间去完善编辑器的各个功能

现在的 JS-Encoder 长这样:

点击进入编辑器

截图未命名.jpg

整体和以前差不多,主要是加了一个侧边栏

下面说一下这些日子里所实现的一些主要功能:

代码格式化

代码格式化是一个非常重要的功能,我们平时写代码的时候很难去注意程序的排版结构,很多换行,符号,空格都被忽略掉,导致代码可读性降低。

其实不光是自己写代码,现在是面向百度编程时代,上网 copy 代码也是常有的事,代码格式正常,看上去美观,一复制到自己的编辑器上就不知道哪儿是哪儿了,自己修改又浪费时间。所以,一个可以自动格式化的工具是非常必要的

那么如何实现自动格式化的功能呢?

我在 npm 上找到了一个叫做 js-beautify 的包,附上链接

js-beautify 可以格式化 htmlcssjs 代码,满足功能要求

比如我想格式化 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();

同样的,格式化 cssjs 的代码也用相同的方法

方法写好了之后就要绑定快捷键,我设置的格式化快捷键是 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 方法将窗口中的旧代码更新为格式化后的代码,大功告成:

GIF.gif

Console

其实在上一文中,就已经实现了 console 的功能,但是只能够实现基本的输出功能,google 自带的 console 不光可以查看输出内容,还可以在里面写 js 代码调试

想要实现这个功能,首先就要在 console 窗口中添加一个可编辑的输入框,再尝试过 inputtextarea 之后我选择给 div 加上 contenteditable="plaintext-only" 属性使其变成可编辑的 div,因为这个 div 可以根据内容自动改变高度

我们在谷歌控制台输入代码的时候,会出现返回值:

GIF1.gif

我们知道 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 并按下回车,iframescript 标签中会插入如下 js

try {
  console.log("1+1"); // '1+1'
  var r = eval("1+1"); // 2
  console.log(r); // 2
} catch (e) {
  console.log(e);
}

效果虽然没有谷歌控制台的好,但至少有是吧 😄

GIF2.gif

命令记忆

接下来实现控制台的记忆功能,也就是按上下方向键可以查看命令历史,如图:

GIF3.gif

首先给可编辑 div 绑定一个 input 事件监听键盘输入,再绑定 keydown.downkeydown.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
  }
  // 省略其他方法
}

switchNextInfoswitchLastInfo 作用相同,只是 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)是一样的

完成效果:

GIF4.gif

console 的功能暂时就是这样了,还存在着很多缺陷,以后会慢慢完善的

颜色转换

侧边栏的 color 选项中可以进行颜色转换和复制

GIF5.gif

颜色转换的方法也很简单:

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 来鼓励鼓励我哦