前言
- 在vue开发过程中,我们使用公共组件或者第三方组件时,大多数都是通过引入npm包的方式在工程中使用,使用这种方式如果引入公共组件的工程比较多,一旦对公共组件修改或者更新,就需要改动很多个工程,每个工程都需要重新发布上线,还是比较麻烦的
- 这里提供一种新的思路,就是把每个公共组件打包成单独的js文件,或者几个公共组件合并打包成一个js文件,然后把js文件上传到cdn服务器,在需要使用组件的宿主工程内,使用script标签引入需要组件的js文件
- 后续即使需要修改公共组件,只需要重新打包,然后上传cdn服务器覆盖之前的js文件即可,宿主工程也不需要改动和发版
- 下面就介绍一下我的公共组件(子工程)和宿主(父工程)是怎么配置和实现的
公共组件(子工程)
目录结构
- examples:本地开发过程中预览组件的目录
- packages:存放组件的目录
- utils:存放公共函数的目录
- .browserslistrc:浏览器兼容配置文件
- index.html:本地启动项目模板
- package-lock.json:锁定安装包版本的文件
- package.json:项目配置文件
- webpack.config.js:webpack配置文件
代码解读
examples

- emamples/styles/reset.css:本地开发环境开发组件过程中设置的重置样式,尽量跟宿主工程的重置样式保持统一,避免组件在子工程和父工程出现差异化展示
* {
padding: 0;
margin: 0;
}
- emamples/APP.vue:本地开发环境的根组件,用来展示组件中的内容,以及传递属性和方法,方便调试组件
<template>
<div id="app">
<Child1 :msg="'子组件1'" @outClick="inClick1" />
<Child2 :msg="'子组件2'" @outClick="inClick2" />
</div>
</template>
<script setup>
import Child1 from '@p/child1';
import Child2 from '@p/child2';
const inClick1 = () => {
console.log('点击组件111');
};
const inClick2 = () => {
console.log('点击组件2');
};
</script>
- emamples/main.js:本地开发环境的项目入口文件,主要是用来初始化vue实例,并引入所需要的插件
import Vue from 'vue';
import App from './App.vue';
import '@e/styles/reset.css';
new Vue({
el: '#app',
render: (h) => h(App)
});
packages

- packages/child1/index.js:单独导出child1组件,可以在webpack配置时当成单独入口
import cp from './index.vue';
export default cp;
- packages/child1/index.vue:简单编写了child1组件内容,只测试了组件的属性和方法的传递
<template>
<div class="child1" @click="onHandleClick">{{ msg || 'child1' }}</div>
</template>
<script setup>
const props = defineProps({
msg: String
});
const emits = defineEmits(['outClick']);
const onHandleClick = () => {
emits('outClick');
};
</script>
<style scoped lang="scss">
.child1 {
height: 50px;
display: flex;
justify-content: center;
align-items: center;
background-color: red;
color: #fff;
}
</style>
- packages/child2/index.js:单独导出child2组件,可以在webpack配置时当成单独入口
import cp from './index.vue';
export default cp;
- packages/child2/index.vue:简单编写了child2组件内容,只测试了组件的属性和方法的传递,为区分child1和child2,两个组件的背景色不同
<template>
<div class="child2" @click="onHandleClick">{{ msg || 'child2' }}</div>
</template>
<script setup>
const props = defineProps({
msg: String
});
const emits = defineEmits(['outClick']);
const onHandleClick = () => {
emits('outClick');
};
</script>
<style scoped lang="scss">
.child2 {
height: 50px;
display: flex;
justify-content: center;
align-items: center;
background-color: blue;
color: #fff;
}
</style>
utils

- utils/getAsyncEntries.js:想要webpack打包时把每个组件打包成单独的js文件,又不想手动一个组件一个组件的配置入口,所以写了一段脚本使用异步回调的方式读取所有组件入口
const fs = require('fs');
const path = require('path');
/**
* 异步读取组件入口
* @return { Promise } 包含所有组件入口的promise
*/
function getAsyncEntries() {
return new Promise((resolve, reject) => {
fs.readdir(path.resolve(__dirname, '../packages'), (err, files) => {
if (err) {
return console.log('packages目录不存在');
}
const entries = {};
for (let file of files) {
entries[file] = `./packages/${file}/index.js`;
}
resolve(entries);
});
});
}
module.exports = getAsyncEntries;
- utils/getSyncEntries.js:上面是使用异步回调的方式读取,下面是同步执行代码读取所有组件入口
const fs = require('fs');
const path = require('path');
/**
* 同步读取组件入口
* @return { Object } 包含所有组件入口的对象
*/
function getSyncEntries() {
const entries = {};
const files = fs.readdirSync(path.resolve(__dirname, '../packages'));
for (let i = 0; i < files.length; i++) {
const name = files[i];
entries[name] = `./packages/${name}/index.js`;
}
return entries;
}
module.exports = getSyncEntries;
单独文件

- .browserslistrc:提供了一种项目共享的目标环境配置,整个项目的babel、eslint、ts等都可以读取到,指定了项目的目标浏览器的范围
> 1%
last 2 versions
not dead
opera >= 12.1
ios >= 10
android >= 5.1
safari >= 6
bb >= 10
and_uc 9.9
- index.html:项目入口的模版,提供了vue实例渲染的容器
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>child</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
- package.json:项目的配置文件,基于webpack5需要安装了webpack、webpack-cli、webpack-dev-server,基于vue框架开发需要安装vue,解析vue文件需要安装vue-loader,解析js文件需要安装babel-loader及相关依赖@babel/core、@babel/preset-env,解析scss文件需要安装sass、sass-loader,解析css文件需要安装css-loader,添加css前缀需要安装postcss-loader、postcss-preset-env,生成html文件需要安装html-webpack-plugin,打包时清空dist文件需要安装clean-webpack-plugin,配置区分不同的环境需要安装cross-env
{
"name": "child",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "webpack server",
"build": "cross-env NODE_ENV=production webpack"
},
"author": "LoveDreaMing",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.23.9",
"@babel/preset-env": "^7.23.9",
"babel-loader": "^9.1.3",
"clean-webpack-plugin": "^4.0.0",
"cross-env": "^7.0.3",
"css-loader": "^6.10.0",
"html-webpack-plugin": "^5.6.0",
"postcss-loader": "^8.1.0",
"postcss-preset-env": "^9.4.0",
"sass": "^1.71.0",
"sass-loader": "^14.1.0",
"vue-loader": "^15.11.1",
"webpack": "^5.90.2",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.2"
},
"dependencies": {
"vue": "^2.7.16"
}
}
- webpack.config.js:webpack配置文件,这里引入同步读取和异步读取组件入口两种方式;这里为什么entry不传入数组格式,而是传入对象格式,通过读取packages里目录的方式,对象里的key就是packages里的组件名文件夹如child1、child2,同时output的filename要读取name变量也是对象的key值,这样输入的js文件刚好也和packages里的目录对应如child1 -> lib.child1.js、child2 -> lib.child2.js,以及output的library里的type设置成开发库常用的umd格式,或者设置成window、self、this等其他格式,只要打包后的组件对象挂载到全局在浏览器环境就都可以通过window.child1、window.child2访问的组件函数
const isProduction = process.env.NODE_ENV === 'production';
// const getSyncEntries = require('./utils/getSyncEntries'); // 同步读取组件入口
const getAsyncEntries = require('./utils/getAsyncEntries'); // 异步读取组件入口
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { VueLoaderPlugin } = require('vue-loader');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
/**
* @type {import('webpack').Configuration}
*/
const config = {
mode: isProduction ? 'production' : 'development',
devtool: isProduction ? false : 'source-map',
// entry: isProduction ? getSyncEntries() : './examples/main.js',
entry: isProduction ? () => getAsyncEntries() : './examples/main.js',
output: {
filename: `lib.[name].js`,
path: path.resolve(__dirname, 'dist'),
library: {
name: '[name]',
type: 'umd'
}
},
resolve: {
alias: {
'@p': path.resolve(__dirname, 'packages'),
'@e': path.resolve(__dirname, 'examples'),
}
},
module: {
rules: [
// 处理css文件
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader',
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: ['postcss-preset-env']
}
}
}
]
},
// 处理scss文件
{
test: /\.scss$/,
use: [
'vue-style-loader',
'css-loader',
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: ['postcss-preset-env']
}
}
},
'sass-loader'
]
},
// 处理js文件
{
test: /\.js$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
]
},
// 处理vue文件
{
test: /\.vue$/,
use: 'vue-loader'
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: 'index.html',
inject: 'body',
minify: false
}),
new VueLoaderPlugin()
].concat(isProduction ? [new CleanWebpackPlugin()] : [])
};
module.exports = config;
宿主工程(父工程)
- 这里使用vue2.7搭建的宿主工程,只介绍改动的文件,需要保持父子工程的vue版本一致
- 把子工程打包的产物直接放到父工程的public文件里,理论上子工程打包后的组件js文件是直接上传到cdn服务器上的,这里只能模拟演示了

- public/index.html:使用script标签加载组件js文件,这样组件函数就会挂载到window对象上,如下图浏览器控制台打印window对象
<!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>
<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 -->
<script src="./lib.child1.js"></script>
<script src="./lib.child2.js"></script>
</body>
</html>

- src/styles/reset.css:全局重置样式及在父工程通过类名控制子工程元素
* {
padding: 0;
margin: 0;
}
.child1 {
font-size: 20px;
}
.child2 {
font-size: 12px;
}
- src/App.vue:一种是使用component组件渲染,另一种是通过全局注册组件的方式渲染,两者都是从window对象上获取的组件函数
<template>
<div id="app">
<component :is="child1" :msg="'局部子组件1'" @outClick="inClick1"></component>
<component :is="child2" :msg="'局部子组件2'" @outClick="inClick2"></component>
<Child1 :msg="'全局子组件1'" @outClick="inClick1" />
<Child2 :msg="'全局子组件2'" @outClick="inClick2" />
</div>
</template>
<script setup>
import { computed } from 'vue';
const child1 = computed(() => window.child1.default);
const child2 = computed(() => window.child2.default);
const inClick1 = () => {
console.log('点击了子组件1');
};
const inClick2 = () => {
console.log('点击了子组件2');
};
</script>
- src/main.js:注册全局组件Child1和Child2
import Vue from 'vue';
import App from './App.vue';
import '@/styles/reset.css';
Vue.config.productionTip = false;
Vue.component('Child1', window.child1.default);
Vue.component('Child2', window.child2.default);
new Vue({
render: (h) => h(App)
}).$mount('#app');
- vue.config.js:如果跟我一样,lib.child1.js和lib.child2.js没有上传到cdn服务器,而是直接放到父工程的public文件下,只需配置publicPath静态资源路径,这样打包后的页面就能访问到静态资源文件了
const { defineConfig } = require('@vue/cli-service');
const isProduction = process.env.NODE_ENV === 'production';
module.exports = defineConfig({
publicPath: isProduction ? './' : '/',
transpileDependencies: true,
devServer: {
port: 9090
}
});
组件合并
- 公共组件的子工程一般来说类似一个组件库,不排除父工程同时使用到了多个公共组件,如果一个一个组件的js文件加载,就容易造成资源请求的浪费,这时就需要把多个组件合并打包成一个js文件,只需做如下修改即可
- 1、在子工程的packages文件夹里创建main.js文件,编写代码如下
import child1 from './child1/index.vue';
import child2 from './child2/index.vue';
export default {
child1,
child2
};
- 2、修改子工程webpack.config.js的entry,也可以修改output里的name变量为一个固定的库名称,我这里还是使用的是name变量即为main,具体代码如下
entry: isProduction ? './packages/main.js' : './examples/main.js',
- 3、修改完子工程,在终端运行
npm run build
,就会在dist文件内打包出lib.main.js文件

- 4、把子工程打包的lib.main.js文件放到父工程的public文件里,并在父工程的public/index.html中引入,如下
<!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>
<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>
<script src="./lib.main.js"></script>
</body>
</html>
- 5、现在就只能从window.main上获取公共组件函数了,具体结构如下图

- 6、父工程src/App.vue改为从window.main获取公共组件函数
<template>
<div id="app">
<component :is="child1" :msg="'局部子组件1'" @outClick="inClick1"></component>
<component :is="child2" :msg="'局部子组件2'" @outClick="inClick2"></component>
<Child1 :msg="'全局子组件1'" @outClick="inClick1" />
<Child2 :msg="'全局子组件2'" @outClick="inClick2" />
</div>
</template>
<script setup>
import { computed } from 'vue';
const child1 = computed(() => window.main.default.child1);
const child2 = computed(() => window.main.default.child2);
const inClick1 = () => {
console.log('点击了子组件1');
};
const inClick2 = () => {
console.log('点击了子组件2');
};
</script>
- 7、父工程src/main.js全局注册组件也改为从window.main获取公共组件函数
import Vue from 'vue';
import App from './App.vue';
import '@/styles/reset.css';
Vue.config.productionTip = false;
Vue.component('Child1', window.main.default.child1);
Vue.component('Child2', window.main.default.child2);
new Vue({
render: (h) => h(App)
}).$mount('#app');
后续补充
- 本文章会持续维护,后续受到评论大神启发或有新的改动都会在这一栏做出补充说明,github源码也会及时调整
子工程-读取组件入口时增加文件夹判断
- 因packages文件里新增main.js文件用来合并组件打包,防止切换组件单独打包和合并打包时报错,utils/getAsyncEntries.js改动如下图1,utils/getSyncEntries.js改动如下图2

父工程-异步组件
- 受评论区大神启发,防止组件js文件报错或者js文件加载失败导致页面其他内容渲染不出来,在父工程的src里新增AsyncApp.vue用来展示异步挂载组件逻辑,具体代码如下
<template>
<div id="app">
<AsyncChild1 :msg="'异步子组件1'" @outClick="inClick1" />
<AsyncChild2 :msg="'异步子组件2'" @outClick="inClick2" />
<div>其他内容</div>
</div>
</template>
<script setup>
import { defineAsyncComponent } from 'vue';
const AsyncChild1 = defineAsyncComponent(() => {
return new Promise((resolve) => {
resolve(window.child1?.default);
});
});
const AsyncChild2 = defineAsyncComponent(() => {
return new Promise((resolve) => {
resolve(window.child2?.default);
});
});
const inClick1 = () => {
console.log('点击了子组件1');
};
const inClick2 = () => {
console.log('点击了子组件2');
};
</script>
- 在父工程的src/AsyncApp.vue里故意把AsyncChild1获取的路径写错如图1,或者在public/index.html里把child1的路径写错如图2,再或者在子工程的packages/child1/index.vue里故意抛出错误再在父工程里引入如图3,以上三种父工程运行或者打包后浏览器都会报错,但是不会影响child2组件和其他内容的渲染

父工程-组件js文件异步加载
- 方式一:在父工程public/index.html里的head标签内插入组件js文件,并在script标签上加上defer如下图1,vue的执行文件一般会被插入到head标签的最尾部如下图2,只要能保证初始化vue在组件js执行之后就可以了

- 方式二:改造父工程src/main.js如下,动态创建script加载组件js文件,并等待组件js文件全部load完成之后再初始化Vue
import Vue from 'vue';
import App from './App.vue';
import '@/styles/reset.css';
Vue.config.productionTip = false;
function addScript(url) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = resolve;
document.body.appendChild(script);
});
}
function init() {
// 组件单文件获取组件函数
Vue.component('Child1', window.child1.default);
Vue.component('Child2', window.child2.default);
// 组件合并后获取组件函数
// Vue.component('Child1', window.main.default.child1);
// Vue.component('Child2', window.main.default.child2);
new Vue({
render: (h) => h(App)
}).$mount('#app');
}
Promise.all([
addScript('./lib.child1.js'),
addScript('./lib.child2.js')
]).then(init);
总结
- vue工程通过加载js文件的方式引入公共组件的原理就是:子工程利用webpack把公共组件打包成js文件,并挂载到全局对象上,浏览器环境即window对象上;然后父工程引入公共组件js文件后,通过从window对象上获取到组件函数,最后通过vue提供的component组件局部渲染组件,或者在入口main.js里全局注册组件再渲染组件
- 采用通过加载js文件的方式引入公共组件的方式,再复杂一些的话,就可以做成低代码平台,公共组件打包成可拖拽的图标,一个图标代表一个组件js文件,拖到页面容器内做可视化预览展示,页面布局完成后,形成配置文件,最后页面通过请求配置文件的方式加载各个组件及配置项,最终形成可直接访问的页面,这算是这种原理更复杂的延伸及使用了
- 演示源码:github.com/LoveDreaMin…