likes
comments
collection
share

vue工程通过加载js文件的方式引入公共组件

作者站长头像
站长
· 阅读数 66

前言

  • 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

vue工程通过加载js文件的方式引入公共组件

  • 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

vue工程通过加载js文件的方式引入公共组件

  • 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

vue工程通过加载js文件的方式引入公共组件

  • 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;

单独文件

vue工程通过加载js文件的方式引入公共组件

  • .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,同时outputfilename要读取name变量也是对象的key值,这样输入的js文件刚好也和packages里的目录对应如child1 -> lib.child1.js、child2 -> lib.child2.js,以及outputlibrary里的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服务器上的,这里只能模拟演示了 vue工程通过加载js文件的方式引入公共组件
  • 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>

vue工程通过加载js文件的方式引入公共组件

  • 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文件 vue工程通过加载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上获取公共组件函数了,具体结构如下图 vue工程通过加载js文件的方式引入公共组件
    • 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 vue工程通过加载js文件的方式引入公共组件 vue工程通过加载js文件的方式引入公共组件

父工程-异步组件

  • 受评论区大神启发,防止组件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组件和其他内容的渲染 vue工程通过加载js文件的方式引入公共组件 vue工程通过加载js文件的方式引入公共组件 vue工程通过加载js文件的方式引入公共组件

父工程-组件js文件异步加载

  • 方式一:在父工程public/index.html里的head标签内插入组件js文件,并在script标签上加上defer如下图1,vue的执行文件一般会被插入到head标签的最尾部如下图2,只要能保证初始化vue在组件js执行之后就可以了 vue工程通过加载js文件的方式引入公共组件 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…
转载自:https://juejin.cn/post/7340240633998082057
评论
请登录