低代码-加载远程组件

3,643 阅读13分钟

低代码-加载远程组件

概要

本文主要介绍了一个基于远程模块的低代码平台的实现。主要探讨低代码平台远程组件加载方案,指出平台因提供丰富组件需按需加载远程组件。介绍了方案一(放在全局对象上)和方案二(amd)的步骤、打包配置、加载逻辑等,分析了优缺点,还提及模块联邦不适用该场景,并得出结论。

前言

低代码开发平台(LCDP)是无需编码(0代码)或通过少量代码就可以快速生成应用程序的开发平台。通过可视化进行应用程序开发的方法,使具有不同经验水平的开发人员可以通过图形化的用户界面,使用拖拽组件和模型驱动的逻辑来创建网页和移动应用程序。这两年越来越多的公司和开发人员开始自研低代码平台来达到降本提效的目的。今天和大家分享一下低代码平台开发过程中遇的一个问题和对应的解决思路。

问题

低代码平台之所以不需要写代码是因为平台提供了很多可配置的组件,让平台的用户可以通过配置的方式生成自己想要的产物。那么如果想要能配置出更多的效果,就需要保证物料库足够丰富。

如果物料组件很多,就需要按需加载组件。现有的开发工具如 webpack、vite 也支持代码分割。但是在低代码平台的开发场景中,平台应用是和组件分离的,需要用户在选择某个组件的时候,要加载远程组件代码。

加载方案

项目地址:github.com/missxiaolin…

组件代码

我们以 vue 框架为例,假如当前有一个组件 A,代码如下,如何远程加载这个组件呢?

<template>
  <div class="powderblue">
    我是插件组件
  </div>
</template>

<script>
export default {
  name: "lButton",
  components: {
  },
  setup(props) {},
};
</script>

打包

import { defineConfig } from "vite";
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import vue from "@vitejs/plugin-vue";
import importToConst from "./vite/importToConst";
const banner = `/*!
* xiaolin ${new Date()}
* (c) 2021 @Energy Monster All Right Reserved..
*/`;

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue(), importToConst()],
  build: {
    outDir: "./dist",
    minify: true,
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true,
      },
    },
    rollupOptions: {
      // plugins: [resolve(), commonjs()],
      // 请确保外部化那些你的库中不需要的依赖
      external: ["vue", "vue-router", "ant-design-vue"],
      output: {
        banner,
        // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
        globals: {
          vue: "Vue",
          "ant-design-vue": "antd",
        },
      },
    },
    lib: {
      // entry: "src/components/index.js", // umd加载方式这样使用
      entry: "src/components/button.vue", // es加载方式这样使用
      name: "button",
      fileName: "button",
      formats: ["es", "umd", "amd"],
    },
  },
});

● 打包出来的结构

image.png

umd

放在全局对象上

步骤

1、打包:组件代码打包为 umd 格式,打包时配置 webpack externals, 使打包产物不包含公共的依赖;

2、上传:打包的组件 js 上传到 cdn;

3、加载:在需要使用组件时,插入一个 script ,在这个 script 中将组件放在一个全局对象上;

4、注册:在 script 插入完成后,从全局对象上获取组件,并进行注册;

组件打包

首先需要增加一个入口文件

import Component from './index.vue';
if(!window.share) {
  window.share = {};
}
window.share[Component.name] = Component;

加载打包组件

<template>
  <div>
    <component :is="component"></component>
  </div>
</template>

<script>
import { onMounted, ref, markRaw } from "vue";
import { Button } from "ant-design-vue";

export default {
  components: {
    [Button.name]: Button,
  },
  setup() {
    const component = ref(null);

    const loadComponent = (name) =>
      new Promise((resolve) => {
        const script = document.createElement("script");
        script.src = `http://localhost:5010/button.umd.js`;
        script.onload = script.onreadystatechange = function () {
          if (
            !this.readyState ||
            this.readyState === "loaded" ||
            this.readyState === "complete"
          ) {
            resolve();
          }
        };
        document.querySelector("head").appendChild(script);
      });

    const addComp = async (name) => {
      await loadComponent(name);
      if (window.share && window.share[name]) {
        console.log(window.share[name]);
        component.value = markRaw(window.share[name]);
      } else {
        console.error(`Component ${name} not found in window.share`);
      }
    };

    onMounted(() => {
      // 动态注册组件
      addComp("lButton");
    });

    return {
      component,
    };
  },
};
</script>

缺点

● 组件的依赖共享,需要依赖提前先放到全局,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 打包配置示例

html 模板

<!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>

组件加载逻辑

// 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 文件的机制;

参考资料

● requirejs 中文文档

● ESModule 系列 ㈠ :演进

● Require.js加载css依赖

EsModule

步骤

● 打包:组件代码打包为 esm 格式,打包时配置webpack externals, 使打包产物不包含公共的依赖;

● 上传:打包的组件 js 上传到 cdn;

● 加载&注册:在需要使用组件时,用 esm 的动态引入获取组件,并进行注册;

组件打包

打包按照前面那个来就行

image.png

加载组件

<template>
  <div>
    <component :is="component"></component>
  </div>
</template>

<script>
import { onMounted, ref, defineAsyncComponent, markRaw, shallowRef } from "vue";
import { Button } from "ant-design-vue";

export default {
  components: {
    [Button.name]: Button,
  },
  setup() {
    const component = ref(null);

    const addComp = async (name) => {
      try {
        const com = await import("http://localhost:5010/button.mjs");
        console.log(com.default);
        component.value = markRaw(com.default);
      } catch (error) {
        console.error(error);
      }
    };

    onMounted(() => {
      // 动态注册组件
      addComp("lButton");
    });

    return {
      component,
    };
  },
};
</script>
避坑

● 忽然发现了一个问题加载报错了

想了想是因为打包出来直接加载这行语句浏览器应该是不识别 import 的

import { openBlock as s, createElementBlock as r } from "vue";

解决方案:

那就让他去全局找就好了

因此写了一个 vite 插件

import { parse } from "acorn";
import MagicString from "magic-string";

export default function importToConst() {
  return {
    name: "import-to-const",
    async generateBundle(options, bundle) {
      const globalDep = options.globals || {};
      if (!Object.keys(globalDep).length) return;

      Object.values(bundle).forEach((data) => {
        const { code } = data;
        if (typeof code !== "string") return;
        const ast = parse(code, {
          ecmaVersion: "latest",
          sourceType: "module",
        });

        const magicString = new MagicString(code);

        // 遍历 AST,查找 ImportDeclaration 节点
        // @ts-ignore
        ast.body.forEach((node) => {
          if (node.type === "ImportDeclaration") {
            const { source, specifiers, start, end } = node;
            const { value } = source;
            let replaceValue = "";
            if (!globalDep[value]) return;
            // 找到需要外部化的依赖,将其替换为全局变量
            replaceValue = globalDep[value];
            // 将 import { computed } from 'vue'; 替换为 const { computed } = Vue;
            if (specifiers.length > 0) {
              let requireStatement = "";
              if (specifiers.length === 1 && !specifiers[0].imported) {
                // const o = Vue;
                requireStatement = `const ${specifiers[0].local.name} = ${replaceValue};`;
              } else {
                requireStatement = `const { ${specifiers
                  .map((specifier, local) => {
                    if (specifier.imported?.name === replaceValue) return "";

                    if (specifier.local?.name) {
                      // const { nextTick: o } = Vue
                      return (
                        specifier.imported?.name + ": " + specifier.local.name
                      );
                    }

                    return specifier.imported?.name;
                  })
                  .filter(Boolean)
                  .join(", ")} } = ${replaceValue};`;
              }

              magicString.overwrite(start, end, requireStatement);
            }
          }
        });

        data.code = magicString.toString();
      });
    },
  };
}
重新打包

image.png

并且全局引入 vue 挂在在 Window 上
import { createApp } from "vue";
import * as Vue from "vue";
import App from "./App.vue";

const app = createApp(App);

import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";
app.use(Antd);
window.Vue = Vue;

app.mount("#root");

再重新接在就不会报错了

vite-plugin-remote-module

使用vite加载远程模块

安装插件
npm i vite-plugin-remote-module
加载组件
<template>
  <div>
    <!-- 方案一 -->
    <!-- <component :is="component"></component> -->
    <!-- 方案二 -->
    <lButton />
  </div>
</template>

<script>
import { onMounted, ref, defineAsyncComponent, markRaw, shallowRef } from "vue";
import { loadRemoteComponent } from "@vite-plugin-remote-module";
import lButton from "@remote/http://localhost:5000/button.vue";

export default {
  components: {
    lButton,
  },
  setup() {
    const component = ref(null);

    onMounted(() => {
      // loadRemoteComponent("http://localhost:5000/button.vue").then((comp) => {
      //   component.value = markRaw(comp);
      // });
    });

    return {
      component,
    };
  },
};
</script>

原理实现

大概思路

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

<template>
    <Demo/>
</template>

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

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

image.png

developer.mozilla.org/zh-CN/docs/…

远程模块->虚拟模块

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里面

image.png

处理动态加载

现在我们迈出了非常关键的一步,直接通过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-{x}.js指定文件名为module-xxx.js的文件

● 如果路径中存在过个变量指定目录,则最多只会生成一层目录,比如/x{x}{y}/最后只会生成//而不是/**/

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

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

image.png

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

image.png

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

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()
  })
}
刷新模块缓存

上面介绍了加载远程模块的具体流程,其思路大概是:拦截远程模块请求,将远程模块下载到本地,将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的组件,就可以看见重新加载了远程模块

image.png

只要触发去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个自定义组件通过本地模块的方式进行打包,这样可以避免大量异步组件导致的页面加载阻塞、以及全部自定义组件都参与打包导致页面体积庞大等常见问题。关于低代码页面的打包,后面在介绍开发一个低代码页面平台时会详细讲解,这里不再赘述。

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

@originjs/vite-plugin-federation

模块联邦方案这边就不多介绍了,看看文档就能接入了