背景
曾经遇到过一种特殊的组件使用场景,在A工程中通过一个基础组件去拉取已构建好存放在CDN
中的组件JS
文件(远程组件),然后挂在到元素中,现在有空将其原理梳理成文。旁白:听说这类远程组件在低代码平台中有所使用。
远程组件核心-Vue
版
组件形式
远程组件应该以什么形式存在呢?
- 文件扩展名为
.vue
形式存在,但是.vue
文件浏览器是识别不了的,需要运行时转换,我们找到了http-vue-loader。先通过Ajax
获取内容,然后解析Template、CSS、Script
,输出一个JS
对象。
- 构建后的
JS
脚本存在。官网提供的vue-loader
,它会解析文件,提取每个语言块,如有必要会通过其它 loader
处理,最后将他们组装成一个 ES Module
,它的默认导出是一个 Vue
组件选项的对象。
怎么构建
这里我们采用webpack
对button
组件进行构建:
- 设置输出配置:
library: 'MyComponent',libraryTarget: 'umd'
,将构建生成的组件选项对象挂在到window.MyComponent
变量
- 不能配置
mini-css-extract-plugin
插件,因为该插件会将组件内部css
抽离
- 样式加载器需要最后需要加上
style-loader
,css-loader
只会把css
模块加载到JS
代码中,生成一个包含css
代码的数组,style-loader
的作用是使用这个数组,将css
注入到style
标签中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| // 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>
来实现:
最终实现
远程组件项目中相关库版本说明:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| "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
组件构建后执行http-server --cors -p 8888
启动本地静态资源服务,配置跨域,以便远程基础组件请求Button.js
源文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| // 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
解决了样式私有化问题的同时也引入了新的问题—样式不易(可)修改,而很多时候,我们是需要对公共组件的样式做微调的。
远程基础组件
这里我准备封装一个RemoteBaseComponent
组件,用来请求并挂载button
组件(远程组件),请求的方式有很多种,详细如下:
动态script
加载
首先需要构建.vue
文件,然后通过动态Script
去加载远端JS
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| // 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
组件选项对象赋值给动态组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| // 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
中,我们执行以下操作来创建渲染函数:
1 2 3 4 5
| export default { render(createElement ) { // createElement could be written h return createElement('div') } }
|
在 Vue 3
中:
1 2 3 4 5 6 7
| 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
执行源代码,将选项对象赋值给动态组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| // 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <!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
获取远程组件源代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| <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.$attrs
和 this.$listeners
向组件传递属性和监听器。再结合使用 inheritAttrs: false
,甚至可以将这些属性和监听器绑定到其他元素上而不根元素:
在 Vue 3.x
的虚拟 DOM
中,事件监听器只是以on
为前缀的属性。因此,监听器被归纳为$attrs
的一部分,从而移除了$listerners
。
参考:Vue 3 迁移策略笔记—— 第19节:移除 $listeners
最终效果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <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>
|
远程组件核心-React
版
组件形式
考虑以下代码,它实现了一个简单的时钟,猜猜最终打印在chrome
控制台中的是什么?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| 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 /> </> ); };
|
事实是每秒,控制台中都会将App
和Text
一起打印出来。
React
组件只是函数。如果你在React
项目中使用过TypeScript
的话, 你可能已经遇见过一个类型:React.FC
,它是FunctionComponent
的缩写, 定义如下:
1
| type FC<P> = (props: P) => ReactElement
|
这意味着一个React
函数组件代表一个函数,它接受props
作为参数并最终返回一个React
元素。
怎么构建
这里我们采用webpack
对button
远程组件进行构建:
- 设置输出配置:
library: 'MyComponent',libraryTarget: 'umd'
,将构建生成的组件选项对象挂在到window.MyComponent
变量
- 不能配置
mini-css-extract-plugin
插件,因为该插件会将组件内部css
抽离
- 样式加载器需要最后需要加上
style-loader
,css-loader
只会把css
模块加载到JS
代码中,生成一个包含css
代码的数组,style-loader
的作用是使用这个数组,将css
注入到style
标签中
- 将
css-loader
中modules
设置为false
,避免样式模块化,因为外部可能需要对公共组件的样式做微调,为了防止样式冲突,可以使用BEM
命名规范
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| // 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
本质上是一种语法糖,它将被编译为一些函数调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| const Something = () => { return ( <p> foo <span>bar</span> </p> ) } // 编译为 const Something = () => { return React.createElement('p', null, 'foo', React.createElement('span', null, 'bar') ) }
|
在React
元素的创建过程中,他将递归地创建所有的子元素, 最终生成一颗元素树。
所以一个组件的渲染过程其实就是一次函数调用。 这就是为什么在前面的例子中我们每一秒都会得到App
和Text
。 组件状态的更新导致了组件的重新渲染,触发了函数调用。
jsx
中的元素应为字符串(用于内置组件)或类/函数(用于复合组件)
最终实现
在将下面Button
组件构建后执行http-server --cors -p 8888
启动本地静态资源服务,配置跨域,以便远程基础组件请求Button.js
源文件
1 2 3 4 5 6 7 8 9 10 11
| // 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
请求加载为示例:
函数组件形式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| 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
组件形式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| 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中的组件动态加载