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

4,396 阅读9分钟

在文章开始之前先展示一下我自己做的在线编译器 JS-Encoder:

点此预览

截图未命名.jpg

大概三四个月之前我开始有了制作在线编译器的想法,在此之前我接触过很多的在线编译器,如CodePenJsBinJsFiddle等,这些都非常优秀且有着庞大的用户群体的编译器。

我一直对在线编译器的实现抱有浓厚兴趣,这些在线编译器支持很多种语言,代码变色,诸多的快捷键以及一些个性化设置,这使得在线编译器看上去和我们在本地下载的编译器软件也不会有太大的区别,我完全不知道这些复杂的功能要怎么实现,于是我观察 CodePenJsBin 代码发现他俩都使用了一个叫 codemirror 的工具。

codemirror

codemirror 是一个用于浏览器的 JavaScript 实现的多功能文本编辑器。它专门用于编辑代码,并带有许多语言模式和插件 ,可实现更高级的编辑功能。

原来这些编译器是依靠 codemirror 来实现的,codemirror 是一个非常复杂的工具,以至于我花了两天时间才熟悉它的配置项。codemirror 本身是采用直接操作 DOM 的方式,而我的项目是使用 Vue + Webpack 构建的,这违反了 Vue 数据驱动 的宗旨,于是我在 npm 上发现了 vue-codemirror 这个工具,采用 Vue 的方式构建代码编辑器

codemirror 有许多配置项,我在自己的项目中用到了如下配置,如果你想看全部配置,可以看这里

cmOptions: {
        // codemirror config
        flattenSpans: false, // 默认情况下,CodeMirror会将使用相同class的两个span合并成一个。通过设置此项为false禁用此功能
        tabSize: 2, // tab缩进空格数
        mode: '', // 模式
        theme: 'monokai', // 主题
        smartIndent: true, // 是否智能缩进
        lineNumbers: true, // 显示行号
        matchBrackets: true, // 匹配符号
        lineWiseCopyCut: true, // 如果在复制或剪切时没有选择文本,那么就会自动操作光标所在的整行
        indentWithTabs: true, // 在缩进时,是否需要把 n*tab宽度个空格替换成n个tab字符
        electricChars: true, // 在输入可能改变当前的缩进时,是否重新缩进
        indentUnit: 2, // 缩进单位,默认2
        autoCloseTags: true, // 自动关闭标签
        autoCloseBrackets: true, // 自动输入括弧
        foldGutter: true, // 允许在行号位置折叠
        cursorHeight: 1, // 光标高度
        keyMap: 'sublime', // 快捷键集合
        extraKeys: {
          'Ctrl-Alt': 'autocomplete',
          'Ctrl-Q': cm => {
            cm.foldCode(cm.getCursor())
          }
        }, //智能提示
        gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], // 用来添加额外的gutter
        styleActiveLine: true // 激活当前行样式
      },

这些配置只是一小部分,但足够实现我想要的功能了

mode 表示当前编辑器使用的语言

theme 表示编辑器使用的配色,官方支持很多种配色,但确没有配色预览,所以我直接使用我熟悉的 monokai 作为主题,因为我比较喜欢 vscode 的配色,所以我找到 monokai.css 文件并修改了许多样式,虽然最后还是和真正的 vscode 主题有差异,但我真的尽力了😭

keymap 我设置为 sublimesublime上大部分快捷键都是可用的

其他的配置我在注释里应该已经说明白了,这里就不解释了

codemirror 的效果还是不错的

截图未命名.jpg

有了 codemirror 这个神器,可以说最难的问题已经解决了,但是还有很多数不清的小问题需要解决

布局

布局方面有很多是参考 JsBin 的,因为我觉得它的界面看起来很简洁,舒服

JsBin 的布局是酱婶儿的:

截图未命名.jpg

分为五个窗口,鼠标放到两个窗口的边界上可以拖动改变窗口大小

GIF.gif

鼠标的拖动会使得一个窗口宽度增加,而另一个窗口宽度减少,但是两个窗口宽度之和是不会改变的

我的思路是:

在点击边界的时候获取两个相邻窗口的宽度,鼠标拖动的时候计算鼠标水平移动距离,并对两个窗口的宽度进行相应增减

由于这五个窗口都是同级的子组件,一个窗口获取另外一个窗口的宽度比较麻烦,于是我将这五个窗口的宽度都放在 Vuex 中储存以便使用,每一个窗口的宽度都随着 Vuex 中宽度信息的改变而改变

成功实现效果:

GIF.gif

为了避免两个窗口重合问题,我设置了 min-width: 100px; 的样式

除了两个窗口的问题之外,还要做到所有窗口宽度随着浏览器宽度变化而改变:

GIF.gif

这个效果也很容易实现,只要在浏览器宽度改变的时候每个窗口的宽度加上或减去 改变宽度/窗口数量 就可以了

Iframe

这是我第一次真正接触 iframe 这个东西,可能他很简单,但我确实在它身上花了不小的力气

我已经解决了窗口拖动的问题,但这对 iframe 是无效的,我一直很困惑,找不出原因,最后突然想到:

iframe 是一个独立的新页面,在 iframe 之外触发的事件不会影响到 iframe 本身,当我用鼠标拖动边界的时候,如果鼠标进入了 iframe 中,那么这个拖动事件就失效了,所以在拖动时候需要先给 iframe 上面加一个透明的遮罩层,这样就不会出现拖不动的问题了

在用户一段时间内不输入任何字符或者用户直接点击运行按钮的时候,需要将编辑器中的 HTMLCSSJavaScript 代码放到 iframe 中,iframe 就会将最终效果展示出来,于是编辑器中的内容我也会放在 Vuex

编译

codemirror 可以实现很多功能,但编译这件事儿他是不干的,像 JsBinCodePen 这样的编译器不只是支持普通的 HTMLCSSJavaScript 而已,他们还支持很多这三种语言的预处理语言

比如我选择了 TypeScript 作为预处理语言,那么编译器就需要先将 TypeScript 转化为 JavaScript 再传给 iframe

由于 JS-Encoder 是一个完全没有后台的编译器,所以要引入其他预处理语言的 npm 包和文件来编译,比如在实现 SassScss 的编译上, 我引入了 Sass.jsSass.worker.js 来编译:

async function compileSass(code) {
  // scss&sass
  if (!loadFiles.get('sass')) {
    const Sass = await require('./sass')
    Sass.setWorkerUrl('static/js/sass.worker.js')
    loadFiles.set('sass', Sass)
  }

  const defSass = loadFiles.get('sass')
  const sass = new defSass()
  
  return new Promise((resolve, reject) => {
    sass.compile(code, result => {
      if (result.status === 0) resolve(result.text)
      else reject(new Error('fail to get result'))
    })
  })
}

这里 loadFiles 只是用于判断是否已经引入过这些文件而已,我是在官方文档上看到这个编译方法的

目前 JS-Encoder 支持MarkDownSassScssLessStylusTypeScriptCoffeeScript, 之后会考虑支持 LiveScriptJSX(React)

控制台

前面已经说了 HTMLCSSJavaScriptiframe 这四个窗口,就剩下 Console 窗口了

Console 窗口用于显示 iframe 控制台中的内容,如果想将这些内容显示在页面上,就要在用户触发这些方法的时候获取到里面的信息,我采取了重写 consolewindow.onerror 的方式:

注意:下面这段 js 代码是写在 iframe 内部的

let consoleInfo = []

// 重写console
if (console) {
  const ableMethods = ['log', 'info', 'debug', 'warn', 'error']
  for (let item of ableMethods) {
    console[item] = function (data) {
      consoleInfo.push({ data, type: item })
    }
  }
}
// 重写window.error
window.onerror = function (msg, url, row, col) {
  consoleInfo.push({ data: { msg, url, row, col }, type: 'error' })
  return false // return false阻止错误在浏览器控制台中报出
}

console 对象远远不止这几个方法,我只是重写了一些常用方法而已

然后要在组件中获取 iframe 元素的 consoleInfo

const consoleInfo = this.$refs.iframeBox.contentWindow.consoleInfo

设置

JS-Encoder 中除了预处理语言的选择之外,还有以下设置

  • 延迟执行时间
    • 每一个可编辑窗口我都设置了 watch 监听值的变化, 频繁的输入会导致方法的频繁触发,所以我设置了防抖函数,在设置的延迟时间内用户没有输入任何字符,才会执行代码
  • 将和tab等宽度的space转化为tab
  • CDN
    • 可以添加外部的 CDN,这样会在执行 JavaScript 之前先引入 CDN
  • CSS
    • 可以添加外部的 CSS,这样会在执行 CSS 之前先通过 link 引入

快捷键

快捷键可以大大加快我们的编码速度,codemirror 也支持快捷键的配置,我们在上面选择了 sublime 作为 keymap 的配置,也就是说,我们在 sublime 上能用的大多数快捷键,都可以在线上编辑器中使用,在官网上,可以看到所有支持的快捷键,但是唯独没有常用的 Tab 快捷键,因为 codemirror 只为 Tab 键开发了缩进功能

在很多编译器软件上写 html 都可以使用 Tab 实现以下功能:

GIF.gif

这种功能来源于一个叫 emmet 的工具,这么好用的东西当然有人为 codemirror 实现,所以我在 npm 上找到了 codemirror-emmet 工具,下面我介绍一下它的用法:

首先导入 codemirrorcodemirror-emmet

import CodeMirror from 'codemirror'
import codeMirrorEmmet from 'codemirror-emmet'

codemirror-emmet返回一个 promise 对象:

codeMirrorEmmet.then(emmet => {
    emmet(CodeMirror) // 将emmet的功能合并到codemirror中
    cmOptions.extraKeys = {
      ...cmOptions.extraKeys,// 因为我之前已经设置了默认的extraKeys,所以这里使用对象扩展符将之前的配置和tab合并
      Tab: cm => {
        if (cm.somethingSelected()) {// 当选中文本并按下tab,文本整体缩进
          cm.indentSelection('add')
        } else if (cm.getOption('mode').indexOf('html') > -1) {// 当前的mode为html时,执行命令emmetExpandAbbreviation
          try {
            cm.execCommand('emmetExpandAbbreviation')
          } catch (err) {
            console.error(err)
          }
        } else {// 前面两个条件都不满足,按下tab就正常缩进
          const spaces = Array(cm.getOption('indentUnit') + 1).join(' ')
          cm.replaceSelection(spaces, 'end', '+input')
        }
      },
      Enter: 'emmetInsertLineBreak'
    }
    cmOptions.emmet = {// 配置emmet项
      markupSnippets: {
        'script:unpkg': 'script[src="https://unpkg.com/"]',
        'script:jsd': 'script[src="https://cdn.jsdelivr.net/npm/"]'
      }
    }
})

这样就实现了图片上的功能

总结

JS-Encoder 从正式开发到现在已经有两个月,因为学业原因,也没有过多的时间投入到开发中。目前 JS-Encoder 还是一个半成品,除了一些基本的之外其实还有很多功能没有或者正在实现,如果感兴趣的话可以在github上关注这个项目。随着更多功能的实现,我会继续更新这篇文章。