Skip to content

NutUI Playground 实现原理

Published:

介绍

在 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、固定可配置文件

3、文件系统对外开放的接口:

当修改了编辑器中的代码后,文件系统中对应文件源码发生变化,触发对其进行重新编译的操作,编译后的代码可以直接在页面中运行,通过 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 的跳转逻辑:

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 中将这些样式引入逻辑导向该空样式文件,以避开报错问题。

最后

在线链接:nutui.jd.com/playground/

Playground 源码地址:jdf2e/nutui

欢迎体验!