浅谈低代码平台远程组件加载方案 https://www.zoo.team/article/low-code
前言
低代码开发平台(LCDP)是无需编码(0 代码)或通过少量代码就可以快速生成应用程序的开发平台。通过可视化进行应用程序开发的方法,使具有不同经验水平的开发人员可以通过图形化的用户界面,使用拖拽组件和模型驱动的逻辑来创建网页和移动应用程序。这两年越来越多的公司和开发人员开始自研低代码平台来达到降本提效的目的。今天和大家分享一下低代码平台开发过程中遇到一个问题和对应的解决思路。
问题
低代码平台之所以不需要写代码是因为平台提供了很多可配置的组件,让平台的用户可以通过配置的方式生成自己想要的产物。那么如果想要能配置出更多的效果,就需要保证物料库足够丰富。
如果物料组件很多,就需要按需加载组件。现有的开发工具如 Webpack 也支持代码分割。但是在低代码平台的开发场景中,平台应用是和组件分离的,需要用户在选择某个组件的时候,要加载远程组件代码。
加载方案
组件代码
我们以 vue 框架为例,假如当前有一个组件 A,代码如下,如何远程加载这个组件呢?
代码语言:javascript复制<template>
<div class="wp">{{text}}</div>
</template>
<script>
import { defineComponent, ref } from 'vue';
import _ from 'lodash';
export default defineComponent({
setup(props) {
console.log(_.get(props, 'a'));
return {
onAdd,
option,
size,
text: 'hello world',
};
},
});
</script>
<style>
.wp {
color: pink;
}
</style>
方案一:放在全局对象上
步骤
- 打包:组件代码打包为 umd 格式,打包时配置 Webpack externals, 使打包产物不包含公共的依赖;
- 上传:打包的组件 js 上传到 cdn;
- 加载:在需要使用组件时,插入一个 script ,在这个 script 中将组件放在一个全局对象上;
- 注册:在 script 插入完成后,从全局对象上获取组件,并进行注册;
组件打包
首先需要增加一个入口文件
代码语言:javascript复制import Component from './index.vue';
if(!window.share) {
window.share = {};
}
window.share[Component.name] = Component;
以上面的入口文件为入口,用 Webpack 打包为 umd 格式
代码语言:javascript复制// 组件打包 Webpack 配置
const path = require('path');
const { VueLoaderPlugin } = require('vue-loader')
module.exports = {
mode: 'production',
entry: path.resolve(__dirname, './comps/index.js'),
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'dist'),
library: { type: 'umd' }
},
module: {
rules: [
{
test: /.vue$/,
use: 'vue-loader',
exclude: /node_modules/,
},
{
test: /.js$/,
loader: 'babel-loader'
},
{
test: /.css$/,
use: [
'vue-style-loader',
'css-loader'
]
}
]
},
plugins: [
new VueLoaderPlugin()
],
externals: {
vue: 'vue',
lodash: 'lodash',
}
};
html 模板
组件公共依赖都需要先加入到模板 html 中
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn/vue.global.js"></script>
<script src="https://cdn/lodash@4.17.21.min.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
组件加载逻辑
代码语言:javascript复制const loadComponent = (name) => new Promise((resolve) => {
const script = document.createElement('script');
script.src=`http://xxx/${name}.js`;
script.onload = script.onreadystatechange = function(){
resolve();
};
document.querySelector('head').appendChild(script);
})
const addComp = async (name) => {
await loadComponent(name);
// 注册组件,其中 app 为 Vue 应用实例对象
app.component(name, window.share[name]);
}
// 动态注册组件
addComp('A');
缺点
- 组件的依赖共享,需要依赖提前先放到全局,html 模板需要较频繁改动;
- 全局对象上要挂载的内容越来越多,影响加载性能,没有做到真正的按需加载;
- 依赖版本难以管理。如 A 组件依赖了 loadsh 1.0, 而 B 组件依赖了 lodash 2.0,但是全局对象上的 lodash,同时挂载两个版本就必然会有冲突,因此版本必须一致;且后续如果某个组件要升级某个依赖的版本,也势必会影响所以其他组件。
方案二:amd
amd 格式也是一种模块化方案,这里我们选择知名度比较高的 require.js 作为 amd 模块加载器。
步骤
- 打包:组件代码打包为 umd 或 amd 格式,打包时配置 Webpack externals,使打包产物不包含公共的依赖;
- 上传:打包的组件 js 上传到 cdn;
- 加载&注册:在需要使用组件时,用 requirejs 获取组件,并进行注册。
组件打包
用 amd 格式来做远程加载时不需要像方案一一样,增加额外的入口文件,可以直接将 .vue 文件作为入口。以下是 Webpack 打包配置示例
代码语言:javascript复制// 组件打包 Webpack 配置
const path = require('path');
const { VueLoaderPlugin } = require('vue-loader')
module.exports = {
mode: 'production',
entry: path.resolve(__dirname, './comps/index.vue'),
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'dist'),
library: { type: 'umd' } // 输出 amd 或者 umd
},
module: {
rules: [
{
test: /.vue$/,
use: 'vue-loader',
exclude: /node_modules/,
},
{
test: /.js$/,
loader: 'babel-loader'
},
{
test: /.css$/,
use: [
'vue-style-loader',
'css-loader'
]
}
]
},
plugins: [
new VueLoaderPlugin()
],
externals: {
vue: 'vue',
lodash: 'lodash',
}
};
html 模板
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="./require.js"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
组件加载逻辑
代码语言:javascript复制// main.js
requirejs.config({
baseUrl: 'https://cdn.xxx.com',
map: {
'*': {
css: 'require-css',
},
},
paths: {
echarts: 'echarts@5.1.1',
vueDemo: 'vue-demo',
vue: 'vue@3.2.37',
moment: 'https://cdn/moment@2.29.1.min',
},
shim: {
'ant-design-vue': ['css!https://cdn/ant-design-vue@2.1.6.min.css'],
},
});
requirejs(['vue', 'vue-demo', 'vue-app'], function (vue, vueDemoModule, VueAppModule) {
const app = Vue.createApp(VueAppModule.default);
app.component('vue-demo', vueDemoModule.default);
const vm = app.mount('#app');
});
缺点
- 平台代码(上述代码的
vue-app
)也需要编译为 amd 格式,然后上传到 cdn 上,开发流程改变,需要定制化的开发平台项目的发布机制。 - 有些第三方库没有提供 amd 或 umd 格式,需要开发者自己开发工具去转换(此过程中可能有很多坑要踩);
优点
- 相比于方案一,组件的依赖可以有版本差异且互相不影响。
- 组件和组件的依赖都可以按需加载,真正做到按需加载。
- 有现成的加载 css 文件的机制;
方案三:ESModule
步骤
- 打包:组件代码打包为 esm 格式,打包时配置 Webpack externals, 使打包产物不包含公共的依赖;
- 上传:打包的组件 js 上传到 cdn;
- 加载&注册:在需要使用组件时,用 esm 的动态引入获取组件,并进行注册;
组件打包
这里需要注意的是,externals 配置项中直接把公共依赖配置为 cdn 地址;
代码语言:javascript复制import path from 'path';
import VueLoader from 'vue-loader';
const VueLoaderPlugin = VueLoader.VueLoaderPlugin;
const __dirname = path.resolve();
export default {
mode: 'development',
entry: path.resolve(__dirname, './src/vue-demo.vue'),
output: {
filename: 'vue-demo.esm.js',
path: path.resolve(__dirname, 'components'),
library: { type: 'module' }
},
experiments: { outputModule: true },
module: {
rules: [
{
test: /.vue$/,
use: 'vue-loader',
exclude: /node_modules/,
},
{
test: /.js$/,
loader: 'babel-loader'
},
{
test: /.css$/,
use: [
'vue-style-loader',
'css-loader'
]
}
]
},
plugins: [
new VueLoaderPlugin()
],
externals: {
vue: 'https://cdn.jsdelivr.net/npm/vue@3.2.37/dist/vue.esm-browser.js',
'lodash': 'https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.js'
}
};
使用上述配置打包后产物,中会把 'vue'
替换为 externals 中的 cdn 地址
// 输入
import Vue from 'vue';
// 输出结果
import Vue from 'https://cdn.jsdelivr.net/npm/vue@3.2.37/dist/vue.esm-browser.js';
组件加载逻辑
代码语言:javascript复制const list = ref([]);
const addComp = async () => {
const VueDemo = await import(/* @vite-ignore */`http://cdn/components/vue-demo.esm.js`)
window.app.component('vue-demo', VueDemo.default);
list.value.push({ key: new Date().valueOf(), name: 'vue-demo' });
}
vite 配置
需要注意的是要保证本地开发时引入的 vue
也是远程的,所以需要在 vite 的配置文件中增加 alias 配置。
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'vue': 'https://cdn.jsdelivr.net/npm/vue@3.2.37/dist/vue.esm-browser.js'
}
}
})
缺点
- 兼容性问题:很多 Webpack 已经支持很好的功能还没有得到主流浏览器的支持
- 对很多第三方依赖的转化处理不完善,缺失完善的解决机制。要将第三方依赖的加载全部交给浏览器本身来接管,那么首先开发工具要做的就是将第三方依赖全部转换为 ESModule 的模块,而现在 npm 上的绝大部分包都是只支持 CommonJS 版本的,因此这里的转换过程通常需要由开发者自己来接管,而这其中有很多底层的问题并没有得到好的解决。同时,在 ESModule 规范推进的过程中,有许多如
exports.default
、exports.__esModule
等利用语法来兼容 ESModule 和 CommonJS 的废案往往也都被 babel 实现,而且被许多开发者使用并且发布到了 npm 上,这就导致了现在 npm 上的许多包中有大量的废弃兼容性代码,而这些代码往往会对开发工具的转化造成阻碍。
优点
- 真正的按需加载
- 代码上更加优雅
关于 Webpack 模块联邦
基于笔者对模块联邦的了解,笔者认为 Webpack 的模块联邦,目前更加适合微前端的场景,但是不太适用于低代码平台的场景。但是笔者对 webpack 模块联邦了解不够深入,判断不一定准确,欢迎有不同意见的小伙伴在评论区讨论。
结论
对比上面三个方案,方案一实现起来最简单,但是没有真正实现按需加载,随着项目规模和需要满足的业务场景的扩大,组件的公共依赖会越来越多。方案二 、方案三 都能实现真正的按需加载,其中 require.js 虽然听上去已经是上个世纪的东西了,但是兼容性和坑相对比较少。说到 ESModule, 虽然有兼容性和上面提到的一些格式转化的问题,但随着近些年 Vite 、Snowpack 的发展,在未来 ESModule 一定是大势所趋,目前笔者也正在将负责的我司内部大屏低代码平台改造为 ESModule 方式加载。
参考
- requirejs 中文文档 [https://www.requirejs-cn.cn/]
- ESModule 系列 ㈠ :演进 [https://mp.weixin.qq.com/s/0AHmP70HnLUZeJWQlRtUKw]
- Require.js 加载 css 依赖 [https://blog.csdn.net/lihefei_coder/article/details/81333036]