介绍
在 NutUI Vue 4.1.0 版本发布时,我们上线了 NutUI Vue Playground,它是一个在线代码调试网站,支持在 Vue SFC 中直接使用 nut-
标签的组件。
与文档中提供的 codesandbox 等站点不同,它并不需要服务器或者依赖 web containter 去运行完整的 node 环境,而是使用 CDN 的方式引入依赖资源文件,并完全运行于用户的浏览器环境,更简洁,响应速度也更快。非常适合作为组件 Demo 代码的在线演示工具。
该站点是基于 Vue 官方提供的 @vuejs/repl 包实现的,REPL 的意思是「Read, Eval, Print, Loop」,即「读取用户输入,解释执行代码,展示输出信息,并循环上述过程」。它提供了一个完备的网页代码编辑器,并内置了对 Vue SFC 模板进行编译、展示输出的逻辑。
Vue REPL 的文件系统
Vue REPL 设计了一个文件系统类,模拟了一个完整的 Vue 项目结构,主要由以下几个模块组成:
1、项目源码
定义一个 File 文件类型,它具有文件名称、文件源码、文件编译后的代码、是否在编辑器中隐藏、文件代码语言类型等一系列可配置可修改的状态,所有的文件则以文件名为 key 存储在一个全局对象中。
2、固定可配置文件
import.json
:页面中的资源配置文件,比如需要用到的 Vue-ESM、NutUI 组件、Icon 包源码等都在该文件中配置,最终会出现在页面的<script type="importmap">
标签中。tsconfig.json
:项目的 TS 配置文件
3、文件系统对外开放的接口:
- 文件操作:新建、删除、重命名、修改文件,编译运行文件,设置编辑器文件激活状态等等。
- 资源版本:修改、重置 Vue、TS 等版本的接口。
- 站点相关:用于生成 hash 路由的序列化方法及其逆向的初始化方法。
当修改了编辑器中的代码后,文件系统中对应文件源码发生变化,触发对其进行重新编译的操作,编译后的代码可以直接在页面中运行,通过 iframe 的形式展示最终的效果。
NutUI Playground 实现
一、全局注册 NutUI 组件与样式
首先,正如在本地项目中使用 NutUI 那样,我们需要在 Vue 的入口文件中注册组件,并引入对应组件的样式。作为一个 Playground 站点,我们需要将所有组件注册为全局组件,并引入全量的样式文件。
注册所有组件的脚本:
// install.js
import NutUI from "@nutui/nutui";
import { getCurrentInstance } from "vue";
export const installNutUI = () => {
const { parent } = window;
const instance = getCurrentInstance();
instance.appContext.app.use(NutUI);
};
Vue 项目的全局入口文件:
<script setup>
import App from "./App.vue";
import { installNutUI } from "./install-nutui.js";
installNutUI();
</script>
<template>
<App />
</template>
上面的代码中,我们可以直接使用 import 语法引用和使用 @nutui/nutui
包中导出的内容,这是因为代码最终会被放在 <script type="module">
标签中,这样的代码只能运行在完整支持 ES6 的现代浏览器中。同时,也需要在 import.json
文件中声明 @nutui/nutui
包的资源地址,就像这样:
<script type="importmap">
{
"imports": {
"@nutui/nutui": "https://cdn.jsdelivr.net/npm/@nutui/nutui/dist/nutui.js"
}
}
</script>
接下来引入所有组件的样式。
由于浏览器 ESM 格式并不支持直接引入样式文件的功能,我们可以直接在页面中创建一个 link 标签,引入样式文件,并同步地将其添加到页面的末尾。
const appendStyle = () => {
return new Promise((resolve, reject) => {
const style = document.createElement("style");
style.innerHTML = "* { margin: 0; padding: 0; }";
document.body.appendChild(style);
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "style.css"; // 样式文件的 CDN 链接
link.onload = resolve;
link.onerror = reject;
document.body.appendChild(link);
});
};
await appendStyle();
以上的所有代码,均通过硬编码的形式生成,它并不运行于 Playground 项目本身,而是作为源代码传入 REPL 提供的文件系统中进行处理。
二、Hash 路由存储代码用于分享
在线 Playground 的一个特色功能就是可分享代码,代码编辑器中的内容与页面地址中的路由参数是一一映射的关系。
我们在 NutUI 文档中这样实现了 demo 的跳转逻辑:
- 输入:文档中的 demo 源代码
- 输出:Playground 站点的在线调试链接
const utoa = (data: string) => {
return btoa(unescape(encodeURIComponent(data)));
};
const serialize = () => {
const files = {
"src/App.vue": code // demo 源代码
};
return "#" + utoa(JSON.stringify(files));
};
然后,在 Playground 站点的文件系统初始化时使用完全相反的操作接收这些代码:
const atou = (b64: string) => {
return decodeURIComponent(escape(atob(b64)));
};
Vue REPL 内部已经实现了类似的逻辑,但是它会对所有文件完整地进行这样的转换和逆向操作,产生很长的 URL 链接。但是实际上,其中包含了大量不需要改变的文件。例如注册与引入样式的全局入口文件、TS 配置文件、import.json
配置文件等等,它在整个站点范围内都应该是一致的。
将这些内容剔除掉,并作为固定不可修改的配置项,链接整体上缩小了很多。
因此我们重写了 Vue REPL 文件系统的构造函数以及其序列化方法:
export class NutUIStore extends ReplStore {
constructor(storeOptions?: StoreOptions, hash?: string) {
super(storeOptions);
if (hash) {
const saved = JSON.parse(atou(hash));
for (const filename in saved) {
const newName = filename.startsWith("src/")
? filename
: `src/${filename}`;
if (!filterFiles.includes(newName)) {
// 过滤掉所有不可变更文件
this.addFile(new File(newName, saved[filename]));
}
}
} else {
const main = new File(APP_FILE, appFileCode, false);
this.addFile(main);
}
// 硬编码生成入口文件、注册脚本
const container = new File(CONTAINER_FILE, containerCode, true);
this.addFile(container);
const install = new File(INSTALL_FILE, installCode.value, true);
this.addFile(install);
// 设置入口文件,设置页面默认展示文件
this.state.mainFile = CONTAINER_FILE;
this.setActive(APP_FILE);
}
serialize() {
// 剔除不可变文件后的序列化方法
const files = this.getFiles();
delete files[IMPORTMAP_FILE];
delete files[TSCONFIG_FILE];
delete files[CONTAINER_FILE.replace("src/", "")];
delete files[INSTALL_FILE.replace("src/", "")];
return "#" + utoa(JSON.stringify(files));
}
}
三、组件库多版本支持
有时候,我们在本地项目中遇到了问题,在 Playground 中的最新版本下却没有复现出来,为了更好的排查问题,这时就需要多版本支持功能。
由于在线 Playground 依赖于全量的 ESM 格式的组件库 CDN 文件,因此目前提供的多版本切换功能的最低版本限制为 4.1.0。
在页面头部增加版本号的下拉选项,默认版本号为 latest
,由于 jsdelivr 以及浏览器缓存机制的原因,它并不总是及时地更新为已发布的最新版本。
当点击版本号时,将会请求 jsdelivr 提供的 npm 包版本号接口,获取到 @nutui/nutui
包的所有信息,接口数据从新到旧排列,接下来过滤掉其中的 beta 版本,并限制其最低版本。
在获取到可选的版本号后,只需要修改 import.json
文件中资源的链接,并触发 REPL 重新运行即可。
四、其他一些实现细节
1、下载项目代码
NutUI Playground 提供了下载代码的功能,它会将当前编写的 SFC 文件作为一个 Vite + Vue 项目的主文件下载为完整的项目代码。
目前已经内置了 unplugin-vue-components
等 vite 插件配置、全局样式变量的引入等,安装依赖后可以直接运行。
2、PC 桌面端适配
移动端组件大多使用触摸事件,在桌面端使用时,鼠标事件是无法触发组件的响应的,Playground 这种以桌面端在线调试为主的站点也会遇到相同的问题。
还好,我们已经提供了 @nutui/touch-emulator
包,它的源码来自 hammerjs,在本地项目入口处引入该文件,就能将所有鼠标事件映射为触摸事件,在桌面端实现对移动端组件的完美兼容。
对于 Playground 而言,则需要将其添加到 import.json
文件中,并在文件系统的入口文件中引入该包。
3、兼容函数式组件样式
NutUI 中有 4 个函数式组件,由于本身通过函数调用,而不使用 vue 标签,不会被 unplugin-vue-components
插件识别到,无法自动引入其对应的样式文件,因此需要单独引入(当然,这个问题可以通过 unplugin-auto-import
插件解决,我们也内置了配置支持)。
而这样的引入语句在 Playground 上是没有意义的,因为我们已经在入口文件中全量引入了组件样式。为了兼容这一场景,可以增加一个空的样式文件,并在 import.json
中将这些样式引入逻辑导向该空样式文件,以避开报错问题。
最后
Playground 源码地址:jdf2e/nutui
欢迎体验!