加载远程组件方案

记得要微笑
English

背景

曾经遇到过一种特殊的组件使用场景,在A工程中通过一个基础组件去拉取已构建好存放在CDN中的组件JS文件(远程组件),然后挂在到元素中,现在有空将其原理梳理成文。旁白:听说这类远程组件在低代码平台中有所使用。

远程组件核心-Vue

组件形式

远程组件应该以什么形式存在呢?

  • 文件扩展名为.vue形式存在,但是.vue文件浏览器是识别不了的,需要运行时转换,我们找到了http-vue-loader。先通过Ajax获取内容,然后解析Template、CSS、Script,输出一个JS对象。
  • 构建后的JS脚本存在。官网提供的vue-loader,它会解析文件,提取每个语言块,如有必要会通过其它 loader 处理,最后将他们组装成一个 ES Module,它的默认导出是一个 Vue组件选项的对象。

image.png

怎么构建

这里我们采用webpackbutton组件进行构建:

  • 设置输出配置:library: 'MyComponent',libraryTarget: 'umd',将构建生成的组件选项对象挂在到window.MyComponent变量
  • 不能配置mini-css-extract-plugin插件,因为该插件会将组件内部css抽离
  • 样式加载器需要最后需要加上style-loadercss-loader只会把css模块加载到JS代码中,生成一个包含css代码的数组,style-loader的作用是使用这个数组,将css注入到style标签中
// webpack.config.js
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const { VueLoaderPlugin } = require('vue-loader');
const webpack = require('webpack');

module.exports = {
  mode: 'production', // 生产环境构建会启动压缩
  entry: {
    'button': './src/components/Button/index.vue',
  },
  output: {
    filename: 'js/[name].js',
    path: path.resolve(__dirname, './dist'),
    library: 'MyComponent',
    libraryTarget: 'umd'
  },
  resolve: {
    extensions: ['.js', '.vue'],
  },
  module: {
    rules: [
      { 
        test: /\.js$/, 
        exclude: /node_modules/, 
        loader: "babel-loader" 
      },
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          esModule: false
        }
      },
      {
        test: /\.(sa|sc|c)ss$/,
        use: [
          'style-loader',
          'css-loader',
          'sass-loader'
        ]
      },
      { 
        test: /\.(jpg|jpeg|png|gif)$/, 
        use: ['url-loader'] 
      }
    ]
  },
  plugins: [
    new webpack.ProgressPlugin(), // 打印构建进度
    new VueLoaderPlugin(),
    new CleanWebpackPlugin({
      cleanOnceBeforeBuildPatterns: ['dist']
    }),
  ],
}

参考:

css-loader style-loader原理探究

挂载方式

我们可以使用动态组件<component :is="xxx"></component>来实现:

image.png

最终实现

远程组件项目中相关库版本说明:

"dependencies": {
    "@babel/core": "^7.15.5",
    "@vue/compiler-sfc": "^3.2.11",
    "babel-loader": "^8.2.2",
    "clean-webpack-plugin": "^4.0.0",
    "css-loader": "^6.2.0",
    "sass": "^1.40.0",
    "sass-loader": "^12.1.0",
    "style-loader": "^3.2.1",
    "url-loader": "^4.1.1",
    "vue": "3.2.2",
    "vue-loader": "^16.5.0",
    "webpack": "^5.52.1",
    "webpack-cli": "^4.8.0"
}

注意:

  • vue3不用vue-template-compiler了,用的@vue/compiler-sfc,另外安装vue-loader要指定16以上的版本
  • 一开始我是用的vue2搭建远程组件工程并构建,但是在远程基础组件中使用时控制台报错createElementBlock is not a function,因为我远程基础组件工程中用的是vue3,而远程组件工程用的vue2,因此需要将vue2升级到vue@3.2.2(好像升级到3.0.0还不行)

参考:https://github.com/element-pl...

远程Button组件

在将下面Button组件构建后执行http-server --cors -p 8888启动本地静态资源服务,配置跨域,以便远程基础组件请求Button.js源文件

// Button.vue
<template>
  <span class="btn" @click="handleClick">{{$attrs.text}}</span>
</template>

<script>
export default {
  name: 'Button',
  data() {
    return {

    }
  },
  methods: {
    handleClick() {
      this.$emit('handleClick');
    }
  }
}
</script>

<style lang="css">
  .btn {
    font-size: 16px;
    color: #da2227;
  }
</style>

注意事项:

如果style标签上添加scoped属性,最后样式可能不会生效,因为远程组件元素上没有注入scopeId,而样式.btn私有化了,当然获取到的选项对象中存在scopeId,你可以手动给远程组件元素加上scopeId,本文没有这样做,是考虑到scoped解决了样式私有化问题的同时也引入了新的问题—样式不易(可)修改,而很多时候,我们是需要对公共组件的样式做微调的。

image-20210916154801787.png

image-20210916155051822.png

远程基础组件

这里我准备封装一个RemoteBaseComponent组件,用来请求并挂载button组件(远程组件),请求的方式有很多种,详细如下:

动态script加载

首先需要构建.vue文件,然后通过动态Script去加载远端JS

// RemoteBaseComponent.vue
<template>
  <component :is="mode" v-bind="$attrs"></component>
</template>

<script>
import { markRaw } from 'vue';

export default {
  name: 'RemoteBaseComponent',
  props: {
    type: String,
  },
  data() {
    return {
      mode: ''
    }
  },
  inheritAttrs: false,
  mounted() {
    this.loadScript().then(() => {
      // 将组件的选项对象赋值给mode
      this.mode = markRaw(window.MyComponent.default);
    })
  },
  methods: {
    loadScript() {
      return new Promise((resolve, reject) => {
        const script = document.createElement('script');
        const target = document.getElementsByTagName('script')[0] || document.head;
        script.type = 'text/javascript';
        script.src = `http://127.0.0.1:8888/${this.type}.js`;
        script.onload = resolve;
        script.onerror = reject;
        target.parentNode.insertBefore(script, target)
      })
    }
  }
}
</script>
局部注册组件

局部注册一个含有script标签的组件,监听onload事件,当加载完成后触发自定义事件将button组件选项对象赋值给动态组件

// RemoteBaseComponent.vue
<template>
  <div>
    <component :is="mode" v-bind="$attrs"></component>
    <remote-js :src="type"></remote-js>
  </div>
</template>

<script>
import { markRaw, h } from 'vue';
window.scriptLoadedevent = new CustomEvent('scriptLoaded'); // 自定义事件

export default {
  name: 'RemoteBaseComponent',
  props: {
    type: String,
  },
  data() {
    return {
      mode: ''
    }
  },
  inheritAttrs: false,
  components: {
    'remote-js': {
      render() {
        return h('script', { 
          type: 'text/javascript', 
          src: `http://127.0.0.1:8888/${this.src}.js`,
          onload: "document.dispatchEvent(scriptLoadedevent)"
        });
      },
      props: {
        src: { type: String, required: true },
      },
    },
  },
  created() {
    // created在onloaded之前执行,mounted不一定在onloaded之后执行,因此在created钩子中监听自定义事件
    document.addEventListener('scriptLoaded', () => {
      this.mode = markRaw(window.MyComponent.default);
    })
  },
}
</script>

注意事项

如果控制台报错:Uncaught TypeError: createElement is not a function,这是因为在 vue 2 中,我们执行以下操作来创建渲染函数:

export default {
  render(createElement ) { // createElement  could be written h
    return createElement('div')
  }
}

Vue 3 中:

import { h } from 'vue'

export default {
  render() {
    return h('div')
  }
}

参考:

Vue 引入远程 JS 文件

using vue-chartjs in vue 3 : createElement is not a function

Ajax请求

首先需要构建.vue文件,然后通过ajax去加载远端JS,获取到远程组件源代码后使用new Function或者eval执行源代码,将选项对象赋值给动态组件

// RemoteBaseComponent.vue
<template>
  <component :is="mode" v-bind="$attrs"></component>
</template>

<script>
import { markRaw } from 'vue';

export default {
  name: 'RemoteBaseComponent',
  props: {
    type: String,
  },
  data() {
    return {
      mode: ''
    }
  },
  inheritAttrs: false,
  mounted() {
    this.loadScript();
  },
  methods: {
    loadScript() {
      fetch(`http://127.0.0.1:8888/${this.type}.js`).then((res) => {
        if (res.status === 200) {
          res.text().then((code) => {
            new Function(`${code}`)()
            // window.eval(code)
            this.mode = markRaw(window.MyComponent.default);
          })
        }
      })
    }
  }
}
</script>
SystemJS

SystemJS 是一个可挂钩的、基于标准的模块加载器。它提供了一个工作流,其中为浏览器中原生 ES 模块的生产工作流(如 Rollup 代码拆分构建)编写的代码可以转换为 System.register 模块格式,以便在不支持原生模块的旧浏览器中工作,几乎可以运行-本地模块速度,同时支持顶级等待、动态导入、循环引用和实时绑定、import.meta.url、模块类型、导入映射、完整性和内容安全策略,并在旧浏览器中兼容回 IE11

首先在项目中引入systemjs

<!DOCTYPE html>
<html lang="">
  <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">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <script src='https://unpkg.com/systemjs@6.10.1/dist/system.js'></script>
    <script src='https://unpkg.com/systemjs@6.10.1/dist/extras/amd.js'></script>
    <script src='https://unpkg.com/systemjs@6.10.1/dist/extras/named-exports.js'></script>
    <script src='https://unpkg.com/systemjs@6.10.1/dist/extras/use-default.js'></script>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

然后在远程基础组件中改用System.import获取远程组件源代码

<template>
  <component :is="mode" v-bind="$attrs"></component>
</template>

<script>
import { markRaw, h } from 'vue';

export default {
  name: 'RemoteBaseComponent',
  props: {
    type: String,
  },
  data() {
    return {
      mode: ''
    }
  },
  inheritAttrs: false,
  mounted() {
    this.loadScript();
  },
  methods: {
    loadScript() {
      window.System.import(`http://127.0.0.1:8888/${this.type}.js`).then((module) => {
        this.mode = module.default;
      })
    }
  }
}
</script>

组件通讯

Vue 2.x 中,我们可以通过this.$attrsthis.$listeners 向组件传递属性和监听器。再结合使用 inheritAttrs: false ,甚至可以将这些属性和监听器绑定到其他元素上而不根元素:

Vue 3.x 的虚拟 DOM 中,事件监听器只是以on为前缀的属性。因此,监听器被归纳为$attrs 的一部分,从而移除了$listerners

参考:Vue 3 迁移策略笔记—— 第19节:移除 $listeners

最终效果

<template>
    <RemoteBaseComponent type="button" text="提交" v-on:handleClick="handleClick"/>
</template>

<script>
import RemoteBaseComponent from './components/RemoteBaseComponent.vue';

export default {
  name: 'App',
  components: {
    RemoteBaseComponent,
  },
  data() {
    return {
    }
  },
  methods: {
    handleClick() {
      console.log('click');
    }
  }
}
</script>

image-20210916155532028.png

远程组件核心-React

组件形式

考虑以下代码,它实现了一个简单的时钟,猜猜最终打印在chrome控制台中的是什么?

const Text = () =>  {
  console.log('Text');
  return <p>Just text.</p>;
};

const App = () => {
  const [clock, setClock] = React.useState(new Date().toISOString());
  console.log('App');

  React.useEffect(() => {
     const interval = setInterval(
       () => setClock(new Date().toISOString()),
       1000
     );
     return () => clearInterval(interval);
  }, []);
  
  return (
    <>
      <div>clock: {clock}</div>
      <Text />
    </>
  );
};

事实是每秒,控制台中都会将AppText一起打印出来。

React组件只是函数。如果你在React项目中使用过TypeScript的话, 你可能已经遇见过一个类型:React.FC,它是FunctionComponent的缩写, 定义如下:

type FC<P> = (props: P) => ReactElement

这意味着一个React函数组件代表一个函数,它接受props作为参数并最终返回一个React元素

怎么构建

这里我们采用webpackbutton远程组件进行构建:

  • 设置输出配置:library: 'MyComponent',libraryTarget: 'umd',将构建生成的组件选项对象挂在到window.MyComponent变量
  • 不能配置mini-css-extract-plugin插件,因为该插件会将组件内部css抽离
  • 样式加载器需要最后需要加上style-loadercss-loader只会把css模块加载到JS代码中,生成一个包含css代码的数组,style-loader的作用是使用这个数组,将css注入到style标签中
  • css-loadermodules设置为false,避免样式模块化,因为外部可能需要对公共组件的样式做微调,为了防止样式冲突,可以使用BEM命名规范
// webpack.config.js
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const webpack = require('webpack');

module.exports = {
  entry: {
    'button': './components/Button/index.jsx',
  },
  output: {
    filename: 'js/[name].js',
    path: path.resolve(__dirname, './dist'),
    library: 'MyComponent',
    libraryTarget: 'umd',
  },
  resolve: {
    extensions: ['.js', '.jsx', '.tsx'],
  },
  module: {
    rules: [
      { 
        test: /\.(js|jsx)$/, 
        exclude: /node_modules/, 
        loader: "babel-loader" 
      },
      {
        test: /\.(sa|sc|c)ss$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: false, // 禁止css modules
            }
          },
          'sass-loader'
        ]
      },
      { 
        test: /\.(jpg|jpeg|png|gif)$/, 
        use: ['url-loader'] 
      }
    ]
  },
  plugins: [
    new webpack.ProgressPlugin(),
    new CleanWebpackPlugin({
      cleanOnceBeforeBuildPatterns: ['dist']
    }),
  ],
}

挂载方式

JSX本质上是一种语法糖,它将被编译为一些函数调用:

const Something = () => {
  return (
    <p>
      foo
      <span>bar</span>
    </p>
  )
}
// 编译为
const Something = () => {
  return React.createElement('p', null,
    'foo',
    React.createElement('span', null, 'bar')
  )
}

React元素的创建过程中,他将递归地创建所有的子元素, 最终生成一颗元素树。

所以一个组件的渲染过程其实就是一次函数调用。 这就是为什么在前面的例子中我们每一秒都会得到AppText。 组件状态的更新导致了组件的重新渲染,触发了函数调用。

jsx中的元素应为字符串(用于内置组件)或类/函数(用于复合组件)

最终实现

远程Button组件

在将下面Button组件构建后执行http-server --cors -p 8888启动本地静态资源服务,配置跨域,以便远程基础组件请求Button.js源文件

// button.jsx
import React from 'react';

import './index.module.scss'; // modules设置为false,不能使用import styles from './index.module.scss';

const Button = (props) => {
  const { handleClick, text } = props;
  return <button className="btn" onClick={handleClick}>{text}</button>
}

export default Button;

远程基础组件

这里我准备封装一个RemoteBaseComponent组件,用来请求并挂载button组件(远程组件),请求的方式有很多种,跟vue差不多,这里我用ajax请求加载为示例:

函数组件形式:

import React, { useCallback, useEffect, useState } from 'react';
import axios from 'axios';

const RemoteBaseComponent = (props: any) => {

  const { type } = props;

  const [Comp, setComponent] = useState<React.FC | null>(null);

  const importComponent = useCallback(() => {
    return axios.get(`http://127.0.0.1:8888/${type}.js`).then(res => res.data);
  }, [type])

  const loadComp = useCallback(async () => {
    // new Function(`${await importComponent()}`)();
    window.eval(`${await importComponent()}`)
    const { default: component } = (window as any).MyComponent;
    setComponent(() => component); // 这里需要注意,不能用setComponent(component),因为compoennt是一个函数,而setComponent接受两种形式的参数,一种是字面量,一种是函数,函数会被执行
  }, [importComponent, setComponent])

  useEffect(() => {
    loadComp();
  }, [loadComp]);

  if (Comp) {
    return <Comp {...props}/>
  }

  return null;
}

export default RemoteBaseComponent;

Class组件形式:

import React, { useCallback, useEffect, useState } from 'react';
import axios from 'axios';

class RemoteBaseComponent extends React.Component<any, any> {
  constructor(props: any) {
    super(props);
    this.state = {
      component: null,
    };
  }

  importComponent(): any {
    return axios.get(`http://127.0.0.1:8888/${this.props.type}.js`).then(res => res.data);
  }

  async componentDidMount() {
    new Function(`${await this.importComponent()}`)();
    const { default: component } = (window as any).MyComponent;
    
    this.setState({
      component: component
    });
  }
  
  render() {
    const C = this.state.component;

    return C
      ? <C {...this.props} />
      : null;
  }
}

export default RemoteBaseComponent;

项目地址:https://github.com/Revelation...

参考:

vue 远程加载sfc组件思路

你可能不知道的动态组件玩法

vue和react中的组件动态加载

阅读 572
avatar
记得要微笑
前端工程师

求上而得中,求中而得下,求下而不得

1k 声望
1.9k 粉丝
0 条评论
你知道吗?

avatar
记得要微笑
前端工程师

求上而得中,求中而得下,求下而不得

1k 声望
1.9k 粉丝
宣传栏