【干货满满】码上掘金很好用,从0到1手把手带你撸一个

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情


最近写文章的时候,用了一下掘金的前端代码在线编辑运行工具-码上掘金,感觉用来写个在线小demo还是挺不错的,之前也用过 codepen等类似的工具,关于此类工具的原理我大概是有点了解的,但具体细节倒还不怎么清楚,于是趁此机会一探究竟

本文将完整实现码上掘金的核心功能(支持lesstypescriptvue2vue3reactreact-ts的在线编辑和预览),完整可运行代码已上传至 github,做了个 在线demo 有兴趣的可以试下

编辑器

关于在线编辑器,已经有很多开源的产品了,例如 CodeMirrorAceMonaco Editor,这几个都是比较成熟的产品随便选哪个都行,码上掘金用的是 Monaco Editor,这个是微软团队出品的且也是 VS Code 使用的编辑器,所以本文也就用这个了

看了下 Monaco Editor 的文档,配置项还是不少的,本来是打算自己封装一个 react 组件,后来发现已经有人封装好了:monaco-react,所以我也就没必要再折腾一遍,直接用这个就完事

import React from "react";
import ReactDOM from "react-dom";

import Editor from "@monaco-editor/react";

function App() {
  return (
   <Editor
     height="90vh"
     defaultLanguage="javascript"
     defaultValue="// some comment"
   />
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

defaultLanguage 用于告知编辑器我希望有个什么样的编程语言环境,这样编辑器会自动注册相关编程语言的开发环境和上下文,这样我们写代码的时候就会有智能提示和高亮

支持传入多种主流编程语言,包括我们这次用到的 javascripttypescripthtmlcssless,但对于 vuereact 这种基于有 external dsl 的框架语言却是不支持的,不过开发者可以针对这些 dsl自定义智能提示和高亮,但很明显这个工作量还是比较高的

于是我就想抄下码上掘金的相关逻辑,结果细看之下发现码上掘金对于这块的功能实现的也不咋滴,甚至有点糊弄(例如 ReactTSjsx语法都给你画红线 щ(`ω´щ) ),没办法只好自己去找找,找了半天终于找到了一份针对 vue dsl 的大概可用的自定义环境代码

虽然这个文件是针对 vue2 的,但 vue2vue3dsl 语法差别并不大,所以都可以用,那么我们就解决了 vue2/vue3 的语法环境了

// vue2
monacoInstance.languages.register({ id: themeMap[languages.vue2.id] })
monacoInstance.languages.setMonarchTokensProvider(themeMap[languages.vue2.id], vue2Language.syntax)
monacoInstance.editor.defineTheme(getCustomThemeNameByLanguage(languages.vue2.id), vue2Language.theme)
// vue3
monacoInstance.languages.register({ id: themeMap[languages.vue3.id] })
monacoInstance.languages.setMonarchTokensProvider(themeMap[languages.vue3.id], vue2Language.syntax)
monacoInstance.editor.defineTheme(getCustomThemeNameByLanguage(languages.vue3.id), vue2Language.theme)

至于 reactdsl 只有 jsxmonaco倒是提供了对 jsx的支持,不过不是那么完善,但在咱们的场景下也够用了

iframe

在编辑器里写好代码后,为了避免上下文混乱,我们一般都会将写好的代码放到沙箱环境中运行,在浏览器端实现沙箱环境最简单最保险的方式就是借助 iframe,这也是码上掘金、codepen 等在线代码编辑运行工具的共同选择

由于码上掘金是有后端的,所以可以将代码传到后端,在后端通过 node将代码编译好之后,再将页面内容回传给 iframe,但我们没有后端,实际上也可以不需要后端,前端也可以完成编译工作,然后将编译好的代码传给 iframe执行即可

iframe通信的方式很多,本文选择 postMessage,主页面将编辑器内的 htmlstylescript代码传给 iframeiframe再分别处理这三类代码

window.addEventListener('message', e => {
  const { html, style, script } = e.data.data
  // 处理样式
  const styleTag = document.createElement('style')
  styleTag.innerHTML = style
  document.head.appendChild(styleTag)
  // 处理 html
  document.body.innerHTML = html
  // 处理 script
  // 这里需要注意一下,因为可能涉及到操作 dom,所以 script 的处理应该放在 style 和 html 的下一个事件循环里
  setTimeout(() => {
    const scriptTag = document.createElement('script')
    scriptTag.innerHTML = content
    document.body.appendChild(scriptTag)
  })
}

这里还有个问题,那就是需要注意 iframe 内上下文混乱的问题,例如在 script代码编辑器里写了一行 const a = 1,传给 iframe处理后,iframe的上下文环境里就有了 a这个变量,如果你再次执行这个代码,iframe就会报错: Identifier 'a' has already been declared,所以在新的代码段进来之前,需要清空 iframe的上下文,也很简单,location.reload() 一下就好

reload 的时机需要父页面来告知,那么父页面与 iframe通信的内容就有两种了,一种是代码更新,一种是reload,在一次代码更新过程中,父页面需要向 iframe发送这两个事件,且这两个事件还需要有一个时间差

当发送 reload时,iframe 刷新上下文,这个时刻父页面是不能立刻发送新代码片段让 iframe执行的,因为这个时候 iframe页面还被初始化好呢,需要等到 iframe完成了 onload事件之后,父页面才能继续发送新代码片段

// 父页面
window.addEventListener('message', (e: MessageEvent<{ event: 'loaded' }>) => {
  if (e.data.event === 'loaded') {
    // 知道 iframe 已经准备好了,可以将新代码片段交给 iframe 执行了
    // ...
  }
})
// iframe
window.onload = function() {
  // 告诉父页面已经准备好了
  window.parent.postMessage({
    event: 'loaded'
  })
}
window.addEventListener('message', e => {
  // reload
  if (e.data.event === 'reload') {
    location.reload()
    return
  }
  // 代码更新
  // ...
})

另外,iframe虽然可以直接作为一个沙箱环境,但其默认的权限可能会有点大,我们还可以通过一些属性来限制一些权限以提高安全性 例如下面是码上掘金给 iframe 设置的属性

<iframe
  sandbox="allow-downloads allow-forms allow-pointer-lock allow-popups allow-presentation allow-same-origin allow-scripts allow-top-navigation-by-user-activation"
  allow="accelerometer; camera; encrypted-media; display-capture; geolocation; gyroscope; microphone; midi; clipboard-read; clipboard-write; web-share"
  scrolling="auto"
  allowFullScreen
  loading="lazy"
></iframe>

对于支持sandbox的浏览器来说,sandbox属性可以设置具体的值,表示逐项打开限制;未设置某一项,就表示不具有该权限

对于上述代码中的 sandbox属性,其表示允许 iframe:下载、表单提交、使用 Pointer Lock API,锁定鼠标的移动、使用window.open()方法弹出窗口、使用 Presentation API、不默认将所有加载的网页都视为跨域、运行脚本、对顶级窗口进行导航,但必须由用户激活

allow用于为iframe指定其特征策略,例如允许 iframe的摄像功能、地理定位、麦克风、支付请求等

scrolling 规定是否在 iframe中显示滚动条,默认值是 auto:在需要的情况下出现滚动条,另外还有 yes:始终显示滚动条(即使不需要)、no:从不显示滚动条(即使需要)

allowFullScreen的值等同于 allow="fullscreen"

iframe指定的网页会立即加载,有时这不是希望的行为,可以通过 loading 属性来指定这个行为:

  • auto:浏览器的默认行为,与不使用loading属性效果相同
  • lazy:开启懒加载,即将滚动进入视口时开始加载
  • eager:立即加载资源,无论在页面上的位置如何

less

有时候使用less这类css预处理语言编写样式会更加方便,但浏览器是不认预处理语言编写的样式的,所以在追加给浏览器之前,需要将 css预处理语言代码编译成 css

在实际开发中,我们一般借助 webpackvite等打包工具完成这个工作,例如对于 less来说,只需要安装 lessless-loader,然后在 webpack上配置一下就好,但 webpack是基于 node的,我们没法在浏览器端用上node,不过less也提供了在浏览器端的 使用方法:将 less.js引入到页面上,然后将 less代码放到 style标签内,这个 style标签的 type属性必须是 text/css

<script src="less.js" type="text/javascript"></script>
<link rel="stylesheet/less" type="text/css" href="styles.less" />

这是即时编译的思路,当然你也可以直接将 less编译成 css,然后传入 iframe,这样就不用在 iframe内引入 less.js 了:

import less from 'less'

const genLess2Css = async (lessStr: string) => {
  return (await less.render(lessStr)).css
}

对于 scssstylus等也是这个思路,这里就不多说了

typescript

浏览器也是不认 typescript的类型系统的,所以在执行 ts代码之前,需要将其编译成 js,在实际开发过程中,我们会使用 babel配合 webpack来完成这个工作,同样的,babel也提供了不需要借助 webpack也能编译代码的库:@babel/standalone

babelStandalone.transform(originContent, {
  filename: 'file.ts',
  presets: ['typescript']
}).code

第一个参数是编写的 typescript代码,第二个参数的 preset 传入一个数组项 typescript,返回的 code 字符串就是编译后的代码

对于如下代码:

(document.getElementById('app') as HTMLDivElement).innerHTML = 'hello world by ts'

将编译为

document.getElementById('app').innerHTML = 'hello world by ts';

react

react代码无法在浏览器端直接执行,所以在执行 react代码之前,需要将其编译成 js,同样可以借助 @babel/standalone 完成这个过程

babelStandalone.transform(originContent, {
  presets: ['react']
}).code

与编译 typescript代码不同的是,所需的 presetsreact

例如对于以下代码

import React, { useState } from 'react'
import ReactDOM from 'react-dom'

function App() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  )
}

ReactDOM.render(
  <App />,
  document.getElementById('app')
)

编译后得到

import React, { useState } from 'react';
import ReactDOM from 'react-dom';

function App() {
  const [count, setCount] = useState(0);
  return /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("p", null, "You clicked ", count, " times"), /*#__PURE__*/React.createElement("button", {
    onClick: () => setCount(count + 1)
  }, "Click me"));
}

ReactDOM.render( /*#__PURE__*/React.createElement(App, null), document.getElementById('app'));

其实主要就是编译了 jsx语法,如果直接将这段代码交给 iframe执行肯定是不行的,因为在执行 import React, { useState } from 'react'; 的时候会报错

现代浏览器已经支持使用 es module的方式导入脚本

<script type="module">
  import React, { useState } from 'react'
  import ReactDOM from 'react-dom'
<script>

与一般脚本的区别是,需要给 script标签加个 type="module"的属性,importexport语法和你在webpack中用到的差不多(本来webpack也就是为了让你提前用上新的语法)

webpack中,类似于 import ReactDOM from 'react-dom'这种写法,webpack会默认到 node_module中去找 reacu-dom模块,但在浏览器端是没有 node_module这个模块的,也没有这种默认行为,from后的字符串必须是一个链接地址(可以是绝对地址也可以是相对地址),指向真正的 es module script脚本

<script type="module">
  import React, { useState } from 'https://pdn.zijieapi.com/esm/bv/[email protected]'
  import ReactDOM from 'https://pdn.zijieapi.com/esm/bv/[email protected]'
<script>

改成上面这种,浏览器就能正常运行了

但如果就想像在 webpack里那样引用也是可以的,import-maps 就是为了解决浏览器中的全局模块而出现的,目前只有基于 Chromium支持这一特性,对于不支持 import-maps 的浏览器, 可以使用 es-module-shims 进行处理

import-maps 使用 json 的形式来定义浏览器中的全局模块:

<script type="importmap">
{
  "imports": {
    "react": "https://pdn.zijieapi.com/esm/bv/[email protected]",
    "react-dom": "https://pdn.zijieapi.com/esm/bv/[email protected]"
  }
}
</script>

这个时候,babel 编译 react的产物就可以直接在浏览器内运行了

react-ts

相比于 reactreact-typescript就是多了类型系统,所以上述 react那一套还是适用的,只不过在编译的时候,需要告知 babel 需要额外编译 typescript

babelStandalone.transform(originContent, {
  filename: 'file.tsx',
  presets: ['react', 'typescript']
}).code

presets的值,除了 react,还需要加一个 typescript,其他步骤就和 react一样了

vue 2.x

vue稍微麻烦点,因为vueDSL语法比较多,所以就有了 vue-loader这个东西,提前将 vue 语法解析一遍,然后再将解析后的产物送给对应的 webpack模块进行处理

vue-loader 没法直接在浏览器端使用,也没有提供能在浏览器端使用的版本,不过 vue-loader 是基于 vue-template-compiler,而这个东西就跟 nodewebpack 没啥关系,且其具备预编译 vue2.x 单文件组件的能力

import { compile, parseComponent } from 'vue-template-compiler/browser'

parseComponent 用于解析 vue2.x 的单文件组件,将 templatescriptstyle 分隔开

对于如下 vue2.x文件

<template>
  <div class="count" @click="addCount">click me {{count}}</div>
</template>
<script>
// import Vue from 'vue'
export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    addCount() {
      this.count += 1
    }
  }
}
</script>

<style>
.count {
  color: red;
}
</style>

经过 parseComponent处理后输出

1.png

对于 script 的内容,其实就是一个正常的 js 对象,不需要任何处理浏览器就能执行;style 也就是正常的css,但一般来说我们都不会直接写 css的,都是写 lessscss这些预编译语言,不过前文已经写过了如何处理 css预编译语言的情况,所以这也不是个问题,直接套用就是了

唯一需要处理的是 template,因为目前还是处于 vue dsl的状态,需要将其处理成浏览器可理解的 js代码,compile方法可以解决这个问题

上面的 template内容经过 compile处理将变成

with(this){return _c('div',{staticClass:"count",on:{"click":addCount}},[_v("click me "+_s(count))])}

如何将模板代码跟script代码联系到一起呢?使用 vuerender函数

export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    addCount() {
      this.count += 1
    }
  },
  render() {
    // 将模板代码放到 render 函数内
    with(this){return _c('div',{staticClass:"count",on:{"click":addCount}},[_v("click me "+_s(count))])}
  }
}

如果你直接执行这段代码的话,vue会给你报个 strict mode 错,因为 with 这种语法不符合 vue 的要求,所以我们还需要模板代码转成 strict mode

于是我又去找了一圈,找到了 vue-template-es2015-compiler,这个库可以将 vue的模板代码转成符合 vue规范的,但是这个库只有 CommonJS 的导出,没法在浏览器端使用,不过我又看了下发现这个库只是对 buble 的一个包装,buble 是可以直接被浏览器所执行的,所以那我就不要 vue-template-es2015-compiler的包装了,直接引用 buble

import * as buble from 'vue-template-es2015-compiler/buble'

const strictModeCompile = (str: string) => {
  return buble.transform(str, {
    transforms: {
      modules: false,
      stripWith: true,
      stripWithFunctional: false
    },
    objectAssign: 'Object.assign'
  }).code
}

现在已经完成了对 vue文件的编译工作,下一步需要将其与 vue结合起来并最终在浏览器端执行,vue是如何执行一个组件的呢?

new Vue({render:h=>h(component)}).$mount('#app');

其中 component 就是上面我们拼接好的 vue script

如果你查看码上掘金的 vue2.x的编译产物,会发现码上掘金是这么引入组件的:

import component from 'data:text/javascript;base64,Ci8vIGltcG9ydCBWdWUgZnJvbSAndnVlJwpleHBvcnQgZGVmYXVsdCB7CiAgZGF0YSgpIHsKICAgIHJldHVybiB7CiAgICAgIGNvdW50OiAwCiAgICB9CiAgfSwKICBtZXRob2RzOiB7CiAgICBhZGRDb3VudCgpIHsKICAgICAgdGhpcy5jb3VudCArPSAxCiAgICB9CiAgfQp9Cg=='

import 的对象是一个链接地址,base64可以当成一个链接地址来用,只需要将 vue组件编译好的代码用 base64编译一下,import 这个 base64的地址就相当于是引入了这个组件,这么做的目的主要是为了兼容编译和组件使用的问题

vue 3.x

vue3.x的组件编译和使用步骤跟vue2.x差不多,只不过所需使用到的库不太一样

vue-template-compiler 不支持 vue3.x 的编译工作,转而由一个新的库来替代:vue/compiler-sfc

import { parse, compileTemplate } from 'vue/compiler-sfc'

parse 代替了 parseComponent,且产物是符合 vue规范的,所以不需要有转成 strict mode这一步

const { descriptor, errors } = parse(originContent, { sourceMap: true })

vue3.x对于 typescript的支持度很好,所以必须得用上typescript

let babelScript = descriptor.script?.content || ''
try {
  babelScript = babelStandalone.transform(babelScript, {
    filename: 'app.ts',
    presets: ['typescript']
  }).code
} catch (err) {
  console.error(err)
  return
}

compileTemplate则代替了 compile方法

const genVue3Template = (template: string) => {
  const { code } = compileTemplate({
    id: 'app',
    filename: 'App.vue',
    source: template
  })
  return base64Encode(code)
}

最后,vue3.x 相比于 2.x 在执行组件的方法上也有点差别

import { createApp } from 'vue'

createApp(component).mount('#app')

总结

本文实现的思路是比较清晰的,无非是将编辑器内输入的内容,当成 htmlstylescript交给 iframe动态执行,遇到浏览器无法直接执行的代码就编译成能执行的,只不过细节还是比较多的,比如需要找到正确的编译库,还需要用正确的方式执行代码,资料查找和调试稍微耗点时间,总体上倒是没有多么大的技术难度

猜你喜欢

转载自juejin.im/post/7124927840276971550