介绍
本文参考了 Vue SFC Playground,基于 importmap 实现了一个在浏览器端的 web playground ------ CodePlayer。在此之上,CodePlayer 还将文件的依赖构建、代码编译等工作抽象为了一个 hooks 和插件系统组成的编译器,并支持了 vue、react(.tsx/.jsx)、html、ts/js、 less/scss/css、json 等多种语法的编译解析。
在线体验:play.fe-dev.cn/
源码仓库:github.com/zh-lx/codep...
架构设计
CodePlayer 整体由架构设计如下图所示,由 UI 层 和 构建层 组成。
- UI 层由以下四部分组成:
- 工具栏(Toolbar):主要包括主题、编辑器的字号、UI 层其他区域的显隐等外观设置,以及刷新预览器和分享等功能
- 文件列表(File List):当前虚拟文件系统中的文件列表
- 代码编辑器(Editor):基于 Monaco 封装的代码编辑器,具备多种语法的代码高亮和代码提示等功能
- 预览区(Preview):用 iframe 去运行编译后的代码,展示效果
- 构建层由虚拟文件系统和编译器两部分组成:
-
虚拟文件系统(Virtual File System):存储文件列表中的文件及代码,一个虚拟文件指的就是一个 File 类型的 js 对象,其包含了文件名(路径)、原始代码和编译后的代码。
-
编译器(Compiler):用于将虚拟文件系统中的源代码,编译为浏览器可执行的 js/css 代码。
-
运行过程
CodePlayer 的整体运行过程如下所示:
当在代码编辑器中编辑代码、或者通过文件列表新增/删除/重命名/更改入口文件时,都会触发虚拟文件系统的更新。Compiler 会监听虚拟文件系统的变动,当发生变动时,Compiler 就会重新编译变动的文件,并将更新后的代码重新注入到 iframe 中,从而更新预览区的视图。
Compiler 的设计
Compiler 的设计借鉴了 webpack 的思想,由 hooks 和 插件系统两部分组成:
- hooks 是由一连串 hook 组成的生命周期,它是基于 hookable 这个库去实现的(与 webpack 底层使用的 tapable 功能类似)
- 插件系统:不同的插件会在不同的 hook 上被调用,执行不同的功能,如编译代码、构建模块依赖、向 iframe 注入编译后的代码等等
Compiler 内部有 init 和 run 两个方法:init 方法只会在初始化时调用一次,在不同的 hook 上注册插件;run 方法则是每次在虚拟文件系统发生变化时执行,按照 hooks 顺序去执行各个 hook 对应的插件的功能
内置插件的实现
从上图可知,CodePlayer 内置了 3 个三个插件:transform plugin、compile-module plugin 和 emit plugin,这三个插件也是 CodePlayer 最核心的部分,所以下面会详细介绍一下这三个插件的具体实现。
transform plugin
transform plugin 的作用是将不同类型的文件代码进行编译浏览器能识别的 .js/.css 代码,内部会根据不同类型的文件后缀,调用不同的编译函数。
css 的转换
- css 文件:代码无需处理
- less 文件的转换:调用 less 库的 less.render 函数,能够将 less 编译为 css
- scss 文件的转换:调用 scss 库的 scss.compileString 函数,能够将 sass/scss 编译为 css
js 的转换
- js 文件:代码无需处理
- ts 文件的转换:调用 sucrase 库的 sucrase.transform 方法,并添加 transform: ['typescript'] 选项(sucrase 是一个类似 babel 的库,与 babel 相比,sucrase 着重于转换 typescript 和 jsx,舍弃了部分场景例如 IE 浏览器特性的兼容)
- jsx/tsx 文件的转换:同样是调用 sucrase 库的 sucrase.transform 方法,在前面的基础上,额外增加一个 jsx 选项:transform: ['typescript', 'jsx'] 选项
vue 文件的转换
vue 文件的转换过程最麻烦,因为一个 vue 文件包含了 template、script 和 style 三部分,好在 @vue/compiler-sfc 为我们提供了这些能力。
对于转换后的 template、script 和 style,可能还包含着 ts、jsx 或者 less/scss 等语法,我们可以借助前面实现的转换能力做进一步转换。
补充知识点:vue 单文件的 scoped 是如何实现的?
- 对于 template 部分:Vue 组件编译后是一个 js 对象,当给这个对象上面挂载一个 __scopeId 属性时,@vue/runtime 为这个 js 对象创建对应的 dom 时将对应的值添加为 dom 的属性。
- 对于 style 部分:在 compileStyle 时会将刚刚生成的 id 作为参数传入时,会对每个选择器添加一个 data-v-xxx 的标签选择器,从而保证样式只对对应组件内的生成的 dom 生效。
compile-module plugin
因为 CodePlayer 中的文件是虚拟文件,需要对于 import 和 export 进行额外处理,compiler-module 主要是对上一步转换后的 js 代码中的 import 和 export 进行处理,构建文件之间的依赖关系。
如何处理文件的依赖关系?
compile-module 构建文件依赖关系过程是 DFS 递归执行的,主要做两件事情:
- 通过 babel 获取 js 代码的 ast 结构,通过 import 语句,确定文件之间的依赖关系。
- 通过文件的依赖关系,构建 js 代码的执行顺序。
import 和 export 语法是如何处理的?
- export 的处理:我们通过 window.modules 全局对象来记录所有有导出模块的文件:以文件名作为 window.modules 中的键,值为一个对象,对象中的属性为文件中通过 export 导出的变量。对应的转换关系如下图所示:
- import 的处理:import 引入模块时也根据引入模块的文件名在 window.modules 中查找对应的键值。对应的转换规则如下图所示:
第三方 npm 包的处理
若导入的模块不是以 ./ 开头,我们视其为第三方 npm 包,即一个远程模块,浏览器原生支持通过 importmap 处理这种引入。
importmap 是一个 JSON 对象,允许开发者在导入 JavaScript 模块时,控制浏览器如何解析模块标识符,它会在解析标识符时与要替换的文本之间建立映射。注意:导入的映射值必须为一个 esm 规范的 js 文件
编译后的代码等同于下图:
提供两个网站可以对 npm 包生成 esm 规范的文件:
emit plugin
compile-module 阶段结束后,最终会将虚拟文件编译为三个数组:links、styles、processed。
emit 阶段主要是将这三个数组生成对应的 html element。在首次渲染时,preview 预览器会创建一个 iframe, emit plugin 会将这些 html elements 写入 iframe 中;更新时不需要再写入整段 html,而是将标记有 replace 的标签删除掉,并将最新的 html element 插入到 html 中。
与主流 Web IDE 的对比
与大家熟知的 WebIDE 如 StackBlitz、CodeSandbox、Vue SFC Playground 等相比,CodePlayer 也具备一些自己的优势:
语法支持的多样性 | 编译速度 | |
---|---|---|
CodePlayer | 中 | 快 |
Vue SFC Playground | 低 | 快 |
StackBlitz | 高 | 慢 |
CodeSandbox | 高 | 慢 |
待优化项
目前 CodePlayer 可以作为小型 demo 的演示站很合适,但对于文件数量多的大型 demo 而言,还有以下待改进项:
- 循环依赖:对于循环依赖的文件,文件中的变量处理会有误
- 热更新:目前修改代码后,会走全量编译的过程,可以通过只对变动的模块重新编译提升性能
总结
以上就是 CodePlayer 实现的全部内容,介绍了其架构设计、编译器实现以及核心的插件具体实现,希望能给大家带去一些学习和帮助,有任何问题欢迎在评论区留言。