使用vite加载远程模块

在上一篇文章中提到了在线预览Vue组件,后面思考了一下,如果能在现有项目中加载远程模块,可以更方便地解决低代码编辑自定义组件的问题。本文沿着这个思路,尝试实现在vite中加载远程模块

<!--more-->

本文代码已放在github上面了,也可以通过npm直接下载

npm i vite-plugin-remote-module

1. 使用场景

先描述一下加载远程模块的场景。

在低代码平台中(或者一些其他类似的场景),需要扩展大量的自定义组件才能满足业务需求。

开发者编写了自定义组件,上传到服务端,在使用低代码编辑器的时候,在组件列表中就可以加载和使用这些自定义组件。这里存在的一个问题是,这些自定义组件该如何保存。

一种方式是保存打包后的javascript模块文件,在用户端可以通过systemJSes module的import http模块、甚至是原始的script标签使用。

这种类似于发布到npm仓库的保存方式最大的缺点是,每次保存都需要重新编译、打包和上传,对于调试和debug而言是非常麻烦的。

另外一种方式就是直接保存组件源码文件,不走任何的打包流程;而是在运行时与页面编辑器一同参与编译和打包。

这种方式相当于将编译的步骤延迟到最后发布完整页面的时候。但这就要求页面编辑器需要在一个类似于开发模式的环境下运行,因为这样才能支持解析打包组件源文件。

按照这个思路,大概的使用方式类似于

<script setup>
import Demo from 'http://localhost:9999/demo.vue'
</script>

<template>
    <Demo/>
</template>

按照设想,demo.vue这个文件是一个完整的Vue SFC文件,引入这个模块时就跟引入了一个本地组件完全一样

import Demo from './demo.vue'

当然上面这段代码目前肯定是没法实现的,本文要解决的就是这个问题。

2. 开发环境

本地开发嘛,首先来准备一个静态资源目录,然后启动一个静态资源服务器,这里省事直接用PHP内置服务了

mkdir static && cd static
php -S localhost:9999

然后可以在这个static目录下面放一点等会要使用远程模块,比如创建一个demo.vue文件啥的

然后是用来测试加载远程模块的的前端开发环境,这里使用vite

npm init vite@latest

按照提示创建一个目录,然后

npm i
npm run dev

默认打开localhost:3000就行了,然后我们修改App.vue里面的代码为上面的测试代码。

首先,不出意外会看见第一个错误:CORS,跨域了嘛。

因为vite使用的是script moudle,被同源策略限制了。解决这个问题,可以配置一下CORS,或者先把文件放在vite项目的public下面,然后改一下引用的路径为localhost:3000临时解决一下(这里可以直接跳过,反正后面不会这样处理...)

解决了跨域的问题之后,会碰见第二个错误

参考MDN文档,这是因为script module加载的远程模块都会认为是JavaScript模块,无法直接把MIME 为空的Vue文件当做JavaScript模块处理。

3. 远程模块->虚拟模块

rollup提供了一个虚拟模块的功能,Vite 插件也是沿用这个特性,支持虚拟模块的。既然直接import http url模块这条路走不通,那折中一下,使用虚拟模块怎么样。

大概思路是

  • 约定一种特殊的import alias,类似于@~之类的,我们这里使用@remote/作为远程模块
  • 当打包器识别到需要加载远程模块时,解析路径,在node端将远程模块下载到本地,再将本地文件作为模块内容返回

照着这个思路实现一下插件

export default function remoteModulePlugin() {
  return {
    name: "vite-plugin-remote-module",
    async resolveId(id) {
      if (/@remote\//.test(id)) {
        const [url] = id.match(/https?.*?$/igm) || []
        if(!url) return id
        return await downloadFile(url)
      }
    },
  };
}

然后实现一下downloadFile这个方法,为了省事这里直接使用request.pip(),也没有考虑错误兼容、文件重名等异常情况

const path = require("path");
const fs = require("fs-extra");
const request = require('request')

function downloadFile(remoteUrl, localPath = `.remote_module`) {
  const folder = path.resolve(__dirname, localPath)
  fs.ensureDirSync(folder)

  const filename = path.basename(remoteUrl)
  const local = path.resolve(folder, `./${filename}`)

  return new Promise((resolve, reject) => {
    let stream = fs.createWriteStream(local);
    request(remoteUrl).pipe(stream).on("close", function (err, data) {
      if (err) reject(err)
      resolve(local)
    });
  })
}

大工告成,写点代码验证一下。首先是在vite.config.js中注册插件,

import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'

import remotePlugin from './remotePlugin'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    remotePlugin() // 新增
  ]
})

然后在App.vue中加载远程模块

<script setup>
import Demo from '@remote/http://localhost:3000/demo.vue'

</script>

<template>
  <Demo/>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

重新启动服务,这个时候再打开浏览器,就可以看见远程的插件已经被正常下载和解析了,同时在项目目录下也可以看见下载的远程文件放在.remote_module里面

vite太好用了!!

4. 处理动态加载

现在我们迈出了非常关键的一步,直接通过import加载远程模块。在实际开发中,还经常需要处理动态加载远程模块的场景

4.1. 异步加载

vitescript module本身是支持异步加载的

<script setup>
import {shallowRef} from 'vue'
const compRef = shallowRef(null)

async function loadDemo1() {
  const ans = await import('@remote/http://localhost:3000/demo.vue')
  compRef.value = ans.default
}
</script>
<template>
  <div class="preview">
    <div class="preview_sd">
      组件列表 <br>
      <button @click="loadDemo1">demo1</button>
    </div>
    <div class="preview_mn">
      <component :is="compRef" v-if="compRef"></component>
    </div>
  </div>
</template>
<style lang="scss"></style>

通过这种方式可以处理代码切割和异步加载的问题

4.2. 动态加载

异步加载存在的一个问题是:import参数中的路径必须是在编译时就确定的,不能传入动态的参数,因此下面这种写法是行不通的

function loadRemoteComponent(url) {
  return import(url).then(ans => {
    return ans.default
  })
}

但在文章开头提到的场景,对于一个组件列表而言,需要通过接口返回组件对应的资源文件,无法在编译时就确定资源路径。

所幸rollup是支持动态加载的,参考rollup plugin/dynamic-import-vars

其原理是:如果一个import的url包含变量,则会将其编译成一个blob模式(类似于正则),然后所有符合这个匹配规则的模块文件都会被加载进来,最后在运行时会根据参数返回一个正确的模块。

了解了这个原理,就不难理解这个插件的设定的一些限制

  • 路径必须以./或者../开头,方便确定最终匹配规则限定的目录
  • 路径必须包含一个文件后缀,方便排除那些非预期模块类型的文件
  • 如果加载的是./当前路径的文件,需要知道文件名的匹配模式,比如./${x}.js是不允许的,只能是./module-${x}.js指定文件名为module-xxx.js的文件
  • 如果路径中存在过个变量指定目录,则最多只会生成一层*目录,比如/${x}${y}/最后只会生成/*/而不是/**/

做出的这些限定,看起来都是为了缩小最终符合匹配要求的文件(毕竟是要在编译的时候把所有符合规则的文件都包含进来)

在vite中中,使用import-analysis插件实现了这个限定的检测

因此对于上面的loadRemoteComponent方法,在控制台会出现如下提示

可以使用跳过这个警告,但还是会影响模块加载

import(/* @vite-ignore */url)

所以对于一个需要动态加载的url组件列表,我们不太容易实现一个纯净版的动态import,需要做一点HACK。

因为我们最终加载的是一个远程的模块,在resolveId的时候都会进行一下处理,那么既然检测是是这个url,那我们就拼一个符合要求的url嘛

function loadRemoteComponent(url) {
  return import(`./@remote/${url}?suffix=.js`).then(ans => {
    return ans.default
  })
}

这样写上去之后,控制台的警告就消失啦!

别高兴的太早了!警告是消失了,但是对应blob能不能匹配到文件就说不准了。

动态import的本质是先将所有满足匹配规则的模块都先打包进来,然后再运行时返回一个完全符合参数匹配的模块。这就意味着在调用import()之前,必须现将对应的文件下载下来。

所幸vite提供了一个configureServer插件配置项,用于注册connect服务器的中间件,因此可以在这里拦截对应的import请求,在这里把文件下载下来

configureServer(server) {
  server.middlewares.use(async (req, res, next) => {
    const id = req.url
    if (isRemoteModuleId(id)) {
      const url = parseUrl(id)
      if (url) {
        await downloadFile(url)
        next()
        return
      }
    }
    next()
  })
}

宾果!这样就是可以骗过vite的import analysis,同时通过中间件在请求对应远程模块前先将其下载下来,看起来跟我们的目标非常接近了。

测试一下,假设我们有三个自定义组件组件

const componentList = [
  {id: 1, name: 'demo', url: 'http://localhost:3000/demo.vue'},
  {id: 2, name: 'demo2', url: 'http://localhost:3000/demo2.vue'},
  {id: 3, name: 'demo3', url: 'http://localhost:3000/demo3.vue'},
]

在点击组件时,需要动态加载组件并在右侧预览区域展示

可以发现正常运行了!!

4.3. 刷新模块缓存

上面介绍了加载远程模块的具体流程,其思路大概是:拦截远程模块请求,将远程模块下载到本地,将import指向下载的本地模块。

这个流程存在一个问题,就是本地模块实际上只是一个镜像,在远程模块在服务端被更新后,本地模块并不会更新,此外由于vite的缓存策略,同一个模块的资源在内容未改变时,并不会重新到服务端去拉取,因此在这种常见下我们只有重启vite服务才能实现更新。

那么怎么刷新被缓存的本地模块呢?

实际上十分简单,在import的url后面增加一个随机的query参数即可,比如下面这种

const componentList = [
  {id: 1, name: 'demo', url: 'http://localhost:3000/demo.vue?update=1'},
  {id: 2, name: 'demo2', url: 'http://localhost:3000/demo2.vue'},
  {id: 3, name: 'demo3', url: 'http://localhost:3000/demo3.vue'},
]

在hrm监听到文件改变时,热更新替换当前模块内容,这时候如果重新import demo1的组件,就可以看见重新加载了远程模块

只要触发去vite服务加载模块就好办了,在resolveId等地方都会重新下载远程模块,完成后续的更新流程。

5. 打包

上面实现的所有模式都是依赖于vite server实现的,即vite 开发模式。如果是最后能够将应用打包,那么该如何实现呢?

在应用中引入的所有的远程模块,如果是静态引入或者异步引入,应该都是会正常走resolveId然后导向下载到本地的那个模块文件,因此可以正常参与打包

# 静态引入
import Demo from '@remote/http://localhost:3000/demo.vue'

// 异步引入
import('@remote/http://localhost:3000/demo.vue')

而动态模块就没有这么好运了,在开发模式下我们通过configureServer骗过了vite,而在生产环境下,由于我们HACK拼接的模块不存在,会返回404。

在动态加载的低代码平台场景下,原本的设计是根据当前配置页面需要哪些自定义组件,然后通过预编译的方式打包出一个独立的文件,不存在动态模块的场景。

比如有100个自定义组件,当前配置页面A使用了其中5个自定义组件,在生产页面时,会将公共页面文件、这5个自定义组件通过本地模块的方式进行打包,这样可以避免大量异步组件导致的页面加载阻塞、以及全部自定义组件都参与打包导致页面体积庞大等常见问题。关于低代码页面的打包,后面在介绍开发一个低代码页面平台时会详细讲解,这里不再赘述。

除了将动态模块转换成批量写死的异步模块之外,业务还可以通过映射表考虑替换动态模块的文件路径,这里暂时就没有继续研究了,有时间可以再尝试一下。

6. 小结

至此,我们就实现了一个可以静态、异步、动态加载远程模块的vite插件,也解决了低代码平台多自定义组件的创建和维护,接下来就是借助vite,去开发一个我理想中的低代码页面开发平台。