手摸手打造类码上掘金在线IDE(一)

3,815 阅读14分钟

前言

不熟悉的朋友可能不知道,我叫老骥,前端切图仔,单位内卷,疯狂加班

最近几个月一直在跟在线IDE打交道,当然,高端一点咱也可以叫他低代码平台,毕竟这个词是流量密码,因为听着高端,看着大气,闻着。。。额,没味

经过这几个月的摸索,只能感叹一句,就这。。。。

时至今日,我终于理解了,什么叫术业有专攻,之所以,这个在线ide 显得高端,不是因为他难,而是干的人少,干这个的人, 只是在这个领域的时间长,有相关的工作经验而已,并不是他厉害,因为我们并不是创造者,我们属于仿写者,所有的思想,理念,结构是,都是别人的,我们仅仅理解改进而已

真的要论起来,他跟写业务的区别并不大,说不好还没有写业务的复杂度高

不信?

那我就手摸手带您,解开他的神秘面纱,还原技术原理的本质

市面上的在线ide类型

作为在线IDE,就是在浏览器端的编辑器,属于比较新鲜的玩意儿,虽然在开发体验上,跟传统的IDE相差甚远,但是我相信,这个一定会是未来的趋势

相比传统IDE有以下劣势

  • 1、基础的代码处理,比如格式化,可能无法实现,代码支持单一,可能每个在线IED只是给每一个单独的语开发到极致, 比如我们今天要讲的js的语种

  • 2、插件机制。对于本地 IDE,一般都会有插件系统来满足不同需求,并且多年积累下插件种类丰富,而都是各家自己的定制,很难出现统一的标准插件机制

  • 3、大型项目无法满足项目规模提升对网络的考验加大,再加上 WebIDE 性能受限于编译运行容器所获取的资源,这些资源有时候还比不上本地机器。

有劣势,那么就会有优势 ,在线IED的优势

  • 1、免安装,所见即所得快速开发,随处可用,在前端领域我们vscode 大家应该都用过, 在每次换一个新电脑,大家有多难受,自己心里都有点数
  • 2、代码安全性能保障, 由于所有的东西都是在线的, 那么就会有用户和权限的概念,如此一来代码的安全性就会有极大保障,妈妈再也不用担心代码被盗了
  • 3、环境统一 相信大家都遇见过辛辛苦苦开发一下午提交了代码,在别人的电脑上死活跑步起来 ,而web 版本就不会有这个问题,他们的环境,完全来自云端,保证了环境的统一性,别人使用你的代码时候,就不会出现尴尬的问题
  • 4、协作编辑 这是一个非常诱人的功能,大家可以类比现在的在线word文档,你就知道有多丝滑了,这也是在线IDE核心竞争力,有了它你就能抛弃那令人作呕的git代码冲突了,还有可能一个在线的聊天窗口,这样一来一边干活,一边摸鱼岂不痛快,领导还不能说啥

在我们前端圈子,充斥着很多在线的编辑器, 他们专为js这个语种设计,可能内置了很多个框架的模板,也可能为单独的框架定制开发,使得我们今天能够在网上顺畅的交流,共同进步。

接下来就让我来跟着大家一块揭开前端领域的在线IDE的原理

在揭开ide原理之前,我们先得了解一下目前市面上的一些主流的在线ide,所谓知己知彼,百战百胜

CodeSandbox

image.png

image.png

CodeSandbox 一个即时可用的,功能齐全的在线 IDE,可在具有浏览器的任何设备上进行Web 开发。使您能够快速启动新项目并快速创建原型。 并且主流的脚手架都支持,例如 create-react-app、 vue-cliparcel等等。

现在这个时候,在线ide一哥毋庸置疑,也是我常用并且天天研究的在线IDE,但是他有一个致命的缺点,国内用户访问太慢了

每次超过半分钟的等待时间,真是不厌其烦。

他的好处就是全,煎炒烹炸,闷溜熬炖全有,然而我钻研过他的源码,耦合性太高,代码结构分的不是很清楚,可读性极差(也有可能是我太菜),不是在这个编辑器里面泡个十年八年的,你很难搞明白他写的是个啥

image.png

CodeSandbox的整个结构包含 三个部分,代码编辑器文件目录系统, 和沙箱运行渲染环境

基本的运行原理如下(感谢前辈大佬们画的图)

image.png

代码编辑器

代码编辑器这一部分其实很好理解,就是一个在线的编辑器 ,可以编辑代码,目前市面上做的比较好的有很多,比如monaco-editorcodemirror 等都可以满足我们的需求

文件目录系统

其实所谓的目录系统,那是官话 ,他本质就是个treeList,也就是我们常用的element-ui 中常用的tree

image.png

只不过我们需要对于他做一些改造,来满足自己的业务需求,他的本质其实非常简单,就是一个递归组件,利用递来实现目录目录层级的结构

之前我也实现过一个,在系列文章开篇科普中就不再赘述,后续详细跟大家一起实现,如有兴趣请查看我写的 treelist

沙箱运行渲染环境

沙箱运行环境,是整个项目中最难的一部分他相当于在浏览器端实现了一个webpack的运行环境,通过配置,来模拟webpack的运行流程

export class ReactPreset extends Preset {
  defaultHtmlBody = '<div id="root"></div>';

  constructor() {
    super('react');
  }

  async init(bundler: Bundler): Promise<void> {
    await super.init(bundler);

    await Promise.all([
      this.registerTransformer(new BabelTransformer()),
      this.registerTransformer(new ReactRefreshTransformer()),
      this.registerTransformer(new CSSTransformer()),
      this.registerTransformer(new StyleTransformer()),
      this.registerTransformer(new ScssTransformer()),
      this.registerTransformer(new UrlTransformer()),
    ]);
  }

  mapTransformers(module: Module): Array<[string, any]> {
    if (/^(?!\/node_modules\/).*\.(((m|c)?jsx?)|tsx)$/.test(module.filepath)) {

      return [
        [
          'babel-transformer',
          {
            presets: [
              [
                'react',
                {
                  runtime: 'automatic',
                },
              ],
            ],
            plugins: [['react-refresh/babel', { skipEnvCheck: true }]],
          },
        ],
        ['react-refresh-transformer', {}],
      ];
    }

    if (/\.(m|c)?(t|j)sx?$/.test(module.filepath) && !module.filepath.endsWith('.d.ts')) {
      return [
        [
          'babel-transformer',
          {
            presets: [
              [
                'react',
                {
                  runtime: 'automatic',
                },
              ],
            ],
          },
        ],
      ];
    }

    if (/\.css$/.test(module.filepath)) {
      return [
        ['css-transformer', {}],
        ['style-transformer', {}],
      ];
    }
    if (/\.s(c|a)ss$/.test(module.filepath)) {
      return [
        ['scss-transformer', {}],
        ['css-transformer', {}],
        ['style-transformer', {}],
      ];
    }
    if (/\.(png|jpeg|svg)$/.test(module.filepath)) {
      return [
        ['url-transformer', {}],
      ];
    }
    throw new Error(`No transformer for ${module.filepath}`);
  }

  augmentDependencies(dependencies: DepMap): DepMap {
    if (!dependencies['react-refresh']) {
      dependencies['react-refresh'] = '^0.11.0';
    }
    dependencies['core-js'] = '3.22.7';
    return dependencies;
  }
}

以上就是一个react 的代码预设,你会发现跟webpack真的很像

而有了这个配置,自然而然的,就能调用不同的loader去处理文件

我们知道在在node环境中webpack编译之后就会将代码发送到浏览器中来执行,而此时,我们的代码就是在浏览器中编译的,这时候就用到了一个函数,eval

eval

eval() 函数会将传入的字符串当做 JavaScript 代码进行执行。

虽然他是个危险函数,但却又沙箱的属性,我们在浏览器端编译好了代码之后, 使用它再好不过了,额,其实也没别的可选.....

export default function (
  code: string,
  require: Function,
  context: { id: string; exports: any; hot?: any },
  env: Object = {},
  globals: Object = {}
) {
  const global = g;
  const process = {
    env: {
      NODE_ENV: 'development',
    },
  }; // buildProcess(env);
  // @ts-ignore
  g.global = global;

  const allGlobals: { [key: string]: any } = {
    require,
    module: context,
    exports: context.exports,
    process,
    global,
    swcHelpers,
    ...globals,
  };

  if (hasGlobalDeclaration.test(code)) {
    delete allGlobals.global;
  }

  const allGlobalKeys = Object.keys(allGlobals);
  const globalsCode = allGlobalKeys.length ? allGlobalKeys.join(', ') : '';
  const globalsValues = allGlobalKeys.map((k) => allGlobals[k]);
  try {
    const newCode = `(function $csb$eval(` + globalsCode + `) {` + code + `\n})`;
    // @ts-ignore
    // 使用eaval 执行函数
    (0, eval)(newCode).apply(allGlobals.global, globalsValues);

    return context.exports;
  } catch (err) {
    logger.error(err);
    logger.error(code);

    let error = err;
    if (typeof err === 'string') {
      error = new Error(err);
    }
    // @ts-ignore
    error.isEvalError = true;

    throw error;
  }
}

好了,CodeSandbox 的基本结构讲解完毕了,后续我们将跟大家一起实现一个类似的功能,揭晓整个沙箱的原理!

StackBlitz

image.png StackBlitz 也是一款在线的IDE 功能和CodeSandbox 类似也支持主流的脚手架。

这其实在功能上和CodeSandbox 已经重了, 那为啥还能这么火爆呢?

原因是他可以在浏览器端跑node,这是CodeSandbox 不具备的,所以他才能杀出来一条血路,当然这是CodeSandbox 现在在追赶并且支持了

比较可惜的是,他们俩的最新代码都没有开源!

可以说他俩现在功能基本重合了,但是他们的实现原理,大相径庭

我们之前说 CodeSandbox 的实现基于在浏览器中构建了webpack ,而StackBlitz则是使用了web container

web container

CodeSandbox 如果要想运行nodejs代码, 则需要在远程服务器上运行整个开发环境,并将将结果传输回给浏览器。

这种方式有一个一些缺陷,首先非常慢并且没有任何安全优势,开发体验极差, 往往启动都需要几分钟,而且还容易出现网络延迟, 没有办法离线工作, 如果网速没有保障,是那么就会经常网络超时

而所谓 WebContainer 其实本质就是可以在浏览器中模拟 Node.js 环境 ,从而直接 浏览器端快速启动项目,可在几毫秒内启动并立即处于在线状态,可以通过链接共享——只需单击一下。它也完全在浏览器中运行,这会产生下列这些关键的好处:

  • 比本地环境更快。 构建完成速度比 yarn/npm 快 20%,包安装完成速度 >= 5 倍。

  • Node.js 应用可以在浏览器中调试。 与 Chrome DevTools 的无缝集成支持本地后端调试,无需安装或扩展。

  • 安全程度高。 所有代码执行都发生在浏览器的安全沙箱内,而不是远程虚拟机或本地二进制文件上。

而他的实现思路具有几个简单的步骤:

    1. 在 Service Worker 中模拟文件系统
    1. 使用 webassembly 编译 node中的一些重要模块
    1. 模拟模块用到的底层 API,比如 http 模块用到的 TCP 模块
    1. 开发环境通过 socket 将结果转发到中心 server,进而分配和映射二级域名
    1. 生产环境直接将运行结果存到数据库即可

他的真正实现其实比较复杂,并且对于兼容性具有较高要求,因为其中还需要设计很多底层的东西以及一些新新的技术 比如Service Workerwebassembly

接下来我们简单的介绍一下

Service Worker

Service workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。这个 API 旨在创建有效的离线体验,它会拦截网络请求并根据网络是否可用来采取适当的动作、更新来自服务器的的资源。它还提供入口以推送通知和访问后台同步 API。

一句话总结就是 一个服务器与浏览器之间的中间人角色,是一种类型的 Web Worker ,具备了同网络拦截,缓存的能力。他具备以下特点

  • 基于web worker(一个独立于JavaScript主线程的独立线程,在里面执行需要消耗大量资源的操作不会堵塞主线程)

  • 在web worker的基础上增加了离线缓存的能力

  • 本质上充当Web应用程序(服务器)与浏览器之间的代理服务器(可以拦截全站的请求,并作出相应的动作->由开发者指定的动作)

  • 创建有效的离线体验(将一些不常更新的内容缓存在浏览器,提高访问体验)

  • 由事件驱动的,具有生命周期

  • 可以访问cache和indexDB

  • 支持推送

  • 并且可以让开发者自己控制管理缓存的内容以及版本

有了这些能耐,那可就是一个天然的node沙箱,node环境就有了基础

webassembly

WebAssembly 是一种运行在现代网络浏览器中的新型代码,并且提供新的性能特性和效果。它设计的目的不是为了手写代码而是为诸如 C、C++和 Rust 等低级源语言提供一个高效的编译目标

以上是mdn的表达,用人话来说 WebAssembly 是一个可移植、体积小、加载快并且兼容 Web 的全新格式

WebAssembly 它具备以下特点:

  • 快速、高效、可移植——通过利用常见的硬件能力,WebAssembly 代码在不同平台上能够以接近本地速度运行。
  • 可读、可调试——WebAssembly 是一门低阶语言,但是它有确实有一种人类可读的文本格式(其标准即将得到最终版本),这允许通过手工来写代码,看代码以及调试代码。
  • 保持安全——WebAssembly 被限制运行在一个安全的沙箱执行环境中。像其他网络代码一样,它遵循浏览器的同源策略和授权策略。
  • 不破坏网络——WebAssembly 的设计原则是与其他网络技术和谐共处并保持向后兼容。

相信看到这,大家依然不明白他是个什么玩意?

其实说白了就是一种字节码类型的代码,他本身不是给我们写代码用的,因为他的可读性极差

image.png

那他是干啥的呢? 用来大幅度提高 Javascript 的性能,同时也不损失安全性

其实就是为了打破js的现有的性能瓶颈, 那么使用它,我们就能将node的一些能力移植到浏览器上来

如此一来我们就能在浏览器中高性能的运行node应用了。

这一部分其实已经被StackBlitz开源了webcontainer-core

CodePen

image.png

image.png CodePen是什么?

官方是这么介绍的

CodePen 是一个完全免费的前端代码托管服务与GitHub Pages 相比,它最重要的优势有:

  • 即时预览。你甚至可以本地修改并即时预览别人的作品。
  • 支持多种主流预处理器。你从不需要手写生产级别的代码,无论是 Jade 、 LESS 、 Sass ,还是 CoffeeScript 、 es6+( Babel ),都能尽情使用。
  • 快速添加外部资源文件。只需在输入框里输入库名, CodePen 就会从 cdnjs 上寻找匹配的 css 或 js 库。
  • 免费用户支持创建三个模板,不是每个作品都需要从白板开始。
  • 优秀的外嵌体验,且支持 oEmbed 。在 WordPress 或 Reddit 等支持 oEmbed 的平台上,只要简单地把链接贴入编辑框,发布后会自动转为嵌入作品。

上述讲了他的主要功能,其实在我看来,他本质上就是一个简单的IDE,功能单一,目标明确,就是为了写个dome 互相交流,仅此而已,无法承载大型项目

码上掘金

码上掘金,jym再熟悉不过了,他和CodePen功能类似, 但是有一个非常大的好处就是他能嵌入在掘金的编辑器中,这算是自家项目开的后门

VueSFCREPL

VueSFC 就更简单了,主要专为vue设计,并且目前来看只兼容vue3

核心原理就是利用vue3的compiler-sfc实现编译 并且同样的使用eval 实现渲染,并且源代码非常适合阅读。

编译

他的编译原理拆开了主要就是处理.vue 文件


import { Store, File } from './store'
import {
  SFCDescriptor,
  BindingMetadata,
  shouldTransformRef,
  transformRef,
  CompilerOptions
} from 'vue/compiler-sfc'
// 编译ts 
import { transform } from 'sucrase'
// 生成id
import hashId from 'hash-sum'
// 导出名字
export const COMP_IDENTIFIER = `__sfc__`
// 编译ts
async function transformTS(src: string) {
  return transform(src, {
    transforms: ['typescript', 'imports']
  }).code
}
// 编译文件
export async function compileFile(
  store: Store,
  { filename, code, compiled }: File
) {
  if (!code.trim()) {
    store.state.errors = []
    return
  }

  if (filename.endsWith('.css')) {
    compiled.css = code
    store.state.errors = []
    return
  }

  if (filename.endsWith('.js') || filename.endsWith('.ts')) {
    if (shouldTransformRef(code)) {
      code = transformRef(code, { filename }).code
    }
    if (filename.endsWith('.ts')) {
      code = await transformTS(code)
    }
    compiled.js = compiled.ssr = code
    store.state.errors = []
    return
  }

  if (!filename.endsWith('.vue')) {
    store.state.errors = []
    return
  }

  const id = hashId(filename)
  // 拆分.vue文件
  const { errors, descriptor } = store.compiler.parse(code, {
    filename,
    sourceMap: true
  })
  if (errors.length) {
    store.state.errors = errors
    return
  }

  if (
    descriptor.styles.some((s) => s.lang) ||
    (descriptor.template && descriptor.template.lang)
  ) {
    store.state.errors = [
      `lang="x" pre-processors for <template> or <style> are currently not ` +
      `supported.`
    ]
    return
  }

  const scriptLang =
    (descriptor.script && descriptor.script.lang) ||
    (descriptor.scriptSetup && descriptor.scriptSetup.lang)
  const isTS = scriptLang === 'ts'
  if (scriptLang && !isTS) {
    store.state.errors = [`Only lang="ts" is supported for <script> blocks.`]
    return
  }

  const hasScoped = descriptor.styles.some((s) => s.scoped)
  let clientCode = ''
  let ssrCode = ''

  const appendSharedCode = (code: string) => {
    clientCode += code
    ssrCode += code
  }
  // 处理script
  const clientScriptResult = await doCompileScript(
    store,
    descriptor,
    id,
    false,
    isTS
  )
  if (!clientScriptResult) {
    return
  }
  const [clientScript, bindings] = clientScriptResult
  clientCode += clientScript

  // script ssr only needs to be performed if using <script setup> where
  // the render fn is inlined.
  if (descriptor.scriptSetup) {
    const ssrScriptResult = await doCompileScript(
      store,
      descriptor,
      id,
      true,
      isTS
    )
    if (ssrScriptResult) {
      ssrCode += ssrScriptResult[0]
    } else {
      ssrCode = `/* SSR compile error: ${store.state.errors[0]} */`
    }
  } else {
    // when no <script setup> is used, the script result will be identical.
    ssrCode += clientScript
  }

  // template
  // only need dedicated compilation if not using <script setup>
  if (
    descriptor.template &&
    (!descriptor.scriptSetup || store.options?.script?.inlineTemplate === false)
  ) {
    const clientTemplateResult = await doCompileTemplate(
      store,
      descriptor,
      id,
      bindings,
      false,
      isTS
    )
    if (!clientTemplateResult) {
      return
    }
    clientCode += clientTemplateResult

    const ssrTemplateResult = await doCompileTemplate(
      store,
      descriptor,
      id,
      bindings,
      true,
      isTS
    )
    if (ssrTemplateResult) {
      // ssr compile failure is fine
      ssrCode += ssrTemplateResult
    } else {
      ssrCode = `/* SSR compile error: ${store.state.errors[0]} */`
    }
  }

  if (hasScoped) {
    // 梳理scoped
    appendSharedCode(
      `\n${COMP_IDENTIFIER}.__scopeId = ${JSON.stringify(`data-v-${id}`)}`
    )
  }

  if (clientCode || ssrCode) {
    appendSharedCode(
      `\n${COMP_IDENTIFIER}.__file = ${JSON.stringify(filename)}` +
      `\nexport default ${COMP_IDENTIFIER}`
    )
    compiled.js = clientCode.trimStart()
    compiled.ssr = ssrCode.trimStart()
  }

  // styles
  let css = ''
  // 处理css
  for (const style of descriptor.styles) {
    if (style.module) {
      store.state.errors = [
        `<style module> is not supported in the playground.`
      ]
      return
    }
    // 处理scoped
    const styleResult = await store.compiler.compileStyleAsync({
      ...store.options?.style,
      source: style.content,
      filename,
      id,
      scoped: style.scoped,
      modules: !!style.module
    })
    if (styleResult.errors.length) {
      // postcss uses pathToFileURL which isn't polyfilled in the browser
      // ignore these errors for now
      if (!styleResult.errors[0].message.includes('pathToFileURL')) {
        store.state.errors = styleResult.errors
      }
      // proceed even if css compile errors
    } else {
      css += styleResult.code + '\n'
    }
  }
  if (css) {
    compiled.css = css.trim()
  } else {
    compiled.css = '/* No <style> tags present */'
  }

  // clear errors
  store.state.errors = []
}
// 编译script模块
async function doCompileScript(
  store: Store,
  descriptor: SFCDescriptor,
  id: string,
  ssr: boolean,
  isTS: boolean
): Promise<[string, BindingMetadata | undefined] | undefined> {
  if (descriptor.script || descriptor.scriptSetup) {
    try {
      const expressionPlugins: CompilerOptions['expressionPlugins'] = isTS
        ? ['typescript']
        : undefined
        // 编译script 文件
      const compiledScript = store.compiler.compileScript(descriptor, {
        inlineTemplate: true,
        ...store.options?.script,
        id,
        templateOptions: {
          ...store.options?.template,
          compilerOptions: {
            ...store.options?.template?.compilerOptions,
          }
        }
      })

      let code = ''
      if (compiledScript.bindings) {
        code += `\n/* Analyzed bindings: ${JSON.stringify(
          compiledScript.bindings,
          null,
          2
        )} */`
      }
      // script 中Default导出 处理
      code +=
        `\n` +
        store.compiler.rewriteDefault(
          compiledScript.content,
          COMP_IDENTIFIER,
          expressionPlugins
        )
        // 处理ts
      if ((descriptor.script || descriptor.scriptSetup)!.lang === 'ts') {
        code = await transformTS(code)
      }

      return [code, compiledScript.bindings]
    } catch (e: any) {
      store.state.errors = [e.stack.split('\n').slice(0, 12).join('\n')]
      return
    }
  } else {
    return [`\nconst ${COMP_IDENTIFIER} = {}`, undefined]
  }
}
// 编译模板
async function doCompileTemplate(
  store: Store,
  descriptor: SFCDescriptor,
  id: string,
  bindingMetadata: BindingMetadata | undefined,
  ssr: boolean,
  isTS: boolean
) {
   // 编译模板
  const templateResult = store.compiler.compileTemplate({
    ...store.options?.template,
    source: descriptor.template!.content,
    filename: descriptor.filename,
    id,
    scoped: descriptor.styles.some((s) => s.scoped),
    slotted: descriptor.slotted,
    ssr,
    ssrCssVars: descriptor.cssVars,
    isProd: false,
    compilerOptions: {
      ...store.options?.template?.compilerOptions,
      bindingMetadata,
      expressionPlugins: isTS ? ['typescript'] : undefined
    }
  })
  if (templateResult.errors.length) {
    store.state.errors = templateResult.errors
    return
  }

  const fnName = ssr ? `ssrRender` : `render`
  // 拼接代码
  let code =
    `\n${templateResult.code.replace(
      /\nexport (function|const) (render|ssrRender)/,
      `$1 ${fnName}`
    )}` + `\n${COMP_IDENTIFIER}.${fnName} = ${fnName}`

  if ((descriptor.script || descriptor.scriptSetup)?.lang === 'ts') {
    code = await transformTS(code)
  }

  return code
}

基本的代码如下,后续我们详细解析

总结

打造在线IDE 系列文章基础篇到此结束,主要介绍了一下各个在线IDE的优势,以及一些使用的简单的原理,

后续会持续更新,欢迎大家关注。。。