使用vite加载远程模块
在上一篇文章中提到了在线预览Vue组件,后面思考了一下,如果能在现有项目中加载远程模块,可以更方便地解决低代码编辑自定义组件的问题。本文沿着这个思路,尝试实现在vite中加载远程模块
npm i vite-plugin-remote-module
使用场景
先描述一下加载远程模块的场景。
在低代码平台中(或者一些其他类似的场景),需要扩展大量的自定义组件才能满足业务需求。
开发者编写了自定义组件,上传到服务端,在使用低代码编辑器的时候,在组件列表中就可以加载和使用这些自定义组件。这里存在的一个问题是,这些自定义组件该如何保存。
一种方式是保存打包后的javascript
模块文件,在用户端可以通过systemJS
、es 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'
当然上面这段代码目前肯定是没法实现的,本文要解决的就是这个问题。
开发环境
本地开发嘛,首先来准备一个静态资源目录,然后启动一个静态资源服务器,这里省事直接用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
模块处理。
远程模块->虚拟模块
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太好用了!!
处理动态加载
现在我们迈出了非常关键的一步,直接通过import加载远程模块。在实际开发中,还经常需要处理动态加载远程模块的场景
异步加载
vite
和script 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>
通过这种方式可以处理代码切割和异步加载的问题
动态加载
异步加载存在的一个问题是: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'},
]
在点击组件时,需要动态加载组件并在右侧预览区域展示
可以发现正常运行了!!
刷新模块缓存
上面介绍了加载远程模块的具体流程,其思路大概是:拦截远程模块请求,将远程模块下载到本地,将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
等地方都会重新下载远程模块,完成后续的更新流程。
打包
上面实现的所有模式都是依赖于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个自定义组件通过本地模块的方式进行打包,这样可以避免大量异步组件导致的页面加载阻塞、以及全部自定义组件都参与打包导致页面体积庞大等常见问题。关于低代码页面的打包,后面在介绍开发一个低代码页面平台时会详细讲解,这里不再赘述。
除了将动态模块转换成批量写死的异步模块之外,业务还可以通过映射表考虑替换动态模块的文件路径,这里暂时就没有继续研究了,有时间可以再尝试一下。
小结
至此,我们就实现了一个可以静态、异步、动态加载远程模块的vite插件,也解决了低代码平台多自定义组件的创建和维护,接下来就是借助vite,去开发一个我理想中的低代码页面开发平台。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。