最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 深入探索Webpack5之Module Federation的“奇淫技巧”

    正文概述 掘金(陌小路)   2021-03-13   1692

    Module Federation介绍

    Module Federation使 JavaScript 应用可以动态运行另一个 JavaScript 应用中的代码,同时可以共享依赖。

    如何理解上面这句话呢,我们可以从实际场景出发来看待这项技术。在我们日常开发中,经常能遇到需要复用一段代码逻辑的场景,一般我们可以从以下几种方式去实现诉求。

    文件抽离方式

    如果说只需要在当前项目下进行复用,那么这个过程是十分简单而快速的,我们可以新建一个js文件,将这段代码放进去,并暴露出来即可实现需求。

    虽说这种方式很简洁并且高效,但它的局限性也是十分明显,它仅支持当前项目下进行使用,如果说我们想要多个项目进行复用,我们就需要把相关代码进行复制粘贴,也就是十分简单而又实用的CV大法,这种方式无疑是最粗暴的,它很可能在我们项目越来越大之后,让你在后续的维护过程中痛哭流涕。

    npm包方式

    对于多项目进行共同依赖的场景,我们一般比较常见的就是维护一个公共npm包的方式,在需要使用的项目中进行安装并进行导入。这种模式可以弥补我们上述方式的不足,实现多项目共用的能力,这是它的优势,避免冗余代码。

    但是,这种方式最显著的缺陷在于,我们每次对这个公共库进行修改的同时,如果想让其他依赖的项目能即时享用新特性,那么我们需要对这些项目进行更新版本并重新装包,然后实施打包上线的流程,对于大型项目并且牵扯到复杂逻辑的情况下,这个代价是十分昂贵的。

    Module Federation方式

    针对上面场景存在的问题,我们来逐一聊聊这哥们的实力。

    还是上述的复用逻辑的场景,如果换成Module Federation来做,我们需要先把需要复用的代码抽离到单文件中(不抽也行,只要能暴露出来就行),接着我们配置一下Module Federation的配置,将这个文件进行导出,使用方就可以直接远程复用这个文件了,并且不需要在乎这个复用文件中的依赖项,它会自动给我们处理好。

    同时如果多个项目使用了这个复用文件,那么我们在对它进行改动时,只需要对这个提供服务的项目进行发版即可,使用方可以实时获取到新代码。

    我们这里针对以上场景不同方案做个简单对比:

    js文件npm包Module Federation
    操作复杂度简单中等中等发版复杂度复杂中等简单可维护性

    深入Module Federation

    通过上面场景介绍,相信你对Module Federation已经有了一个大概的印象——这哥们能直接使用远端项目的指定js文件。

    接下来,我们来继续探究这项技术对我们日常业务带来的福音。

    深入探索Webpack5之Module Federation的“奇淫技巧”

    背景

    众所周知,在当前VueReact框架横行的时代下,我们写的最多的莫过于各式各样的组件,不仅如此,为了项目的可维护性,我们也会抽离出许许多多的公共组件或方法,也正是在这个需求下,催生了一批批优质的第三方组件库或者utils库。

    同样的,这些第三方库也面临着我们开篇聊到的问题,如何快速而方便的进行发版和维护、如何降低用户使用负担这也是一个需要值得大家思考的问题。

    在一些大型项目中,我们往往需要安装大量的第三方包,在webpack的帮助下,我们虽然能方便的进行项目的开发,但不得不提嘴一说的地方就是,在项目优化做的不好的情况下,出现一行代码改动等几分钟还真是有可能的事。

    也正是因为这项缺陷,诸如尤大等各个前端大牛们都在探索no webpack的方式,比如最近大火的vite 2.0的诞生,也的确给深受webpack“折磨”的许多开发带来了新的解决方案。基于这个角度来看,难道webpack除了不断的走优化之路来降低构建时间之外就真的没有什么其他的好的办法了吗?

    本文对应实践项目地址:项目源码地址

    Module Federation应用场景

    提供公共服务能力

    这一点笔者主要想提的就是它能提供远程公共组件和js模块的能力。

    怎么说呢,如果运用了这项能力,我们就不再需要组件库npm包这种东西了,直接抽一个项目用来承载这项能力,所有的公共组件直接通过一个远程链接就能直接获取,完全不需要安装,就可直接使用。

    话不多说,上码:

    <template>
      <div id="app">
        <h1>Hello Other Vue2</h1>
        <HelloWorld msg="我是本地Vue组件"></HelloWorld>
        <HelloWorld2 msg="我是远程Vue组件"></HelloWorld2>
      </div>
    </template>
    
    <script>
    import HelloWorld2 from '@v2hw/HelloWorld'
    import HelloWorld from './components/HelloWorld'
    export default {
      name: 'App',
      components: {
        HelloWorld2,
        HelloWorld
      }
    }
    </script>
    

    我们先不用管具体怎么配置的,只需要知道这个import HelloWorld2 from '@v2hw/HelloWorld'是一个用来导入一个远程的组件的,然后我们就能直接进行注册使用了。

    看看效果:

    深入探索Webpack5之Module Federation的“奇淫技巧”

    左边渲染的是当前项目下的组件,右边则表示的是从远端获取并渲染的组件,是不是十分刺激。

    同理,这种远程引入的能力一样适用js模块。

    让重复构建什么的都见?去吧

    发散思路,除了能进行组件复用,我们还能用它做什么?

    让我们细细回想一下,我们日常业务中,大多数情况下,我们开发的项目体积中,占比最大的应该当属我们那让人又爱又恨的第三方包了吧。不仅如此,这些第三方包一般是不会进行修改的,所以每次构建(先不管缓存),我们似乎都是在做重复劳动,那我们能不能借助Module Federation能力将这些第三方包都放在远端进行维护,我们只需要用runtime的方式引入就可以了。

    说干就干,码来:

    <template>
    	<div class="hello">
    		<Card hoverable style="width: 240px">
    			<img
    				slot="cover"
    				
    				src="https://os.alipayobjects.com/rmsportal/QBnOOoLaAfKPirc.png"
    			/>
    			<CardMeta >
    				<template slot="description">
    					{{ msg }}
    				</template>
    			</CardMeta>
    		</Card>
    	</div>
    </template>
    
    <script>
    import { Card } from '@v2hw/ant-design-vue'
    // import { Card } from 'ant-design-vue'
    export default {
    	name: "HelloWorld",
    	props: {
    		msg: String,
    	},
      components: {
        Card,
        CardMeta: Card.Meta
      }
    };
    </script>
    
    

    这里笔者将原先直接从当前项目导入ant-design-vue改成从远程项目直接获取的方式,看是否能够带来构建性能的提升。

    光从代码上看无法明显的看出两种方式带来的差距,我们通过他们的运行结果来进行分析(注意红色圈圈中的内容即可):

    深入探索Webpack5之Module Federation的“奇淫技巧”](imgtu.com/i/6MCQAS)

    我们从图中来看,上面一个圈中是采用远程依赖的构建时间,后者就是采用本地第三方包方式的构建时间,一个是2713ms、一个是4695ms,提升速度:**42%**左右,这还仅仅只是一个第三方包就能带来如此大的提升,一般稍微大点的项目第三方包的数量都是十分庞大的,如果全量移交远端,那么我们每次代码编写仅仅只需要构建我们编写的代码,而无需关心第三方包,这种提升的确让人十分激动。

    这个时候可能会有笔者来问了,那我直接将所有第三方全部用cdn形式引入不就完事了,和你这个达到的效果也是一样的。

    诚然,这种方式和cdn方式引入然后配合webpackexternal有着异曲同工之妙,但采用webpack module federation可以把这个包的控制权掌握在自己手里,我们不用担心如果哪天这个cdn链接挂了,我们却毫不知情,并且对于很多根本不提供cdn方式的第三方包,就没法达到你想要的效果了,而这也仅仅只是webpack module federation能力中的一项而已。

    详细配置方式

    这里我们需要借助一个webpack5自带的插件ModuleFederationPlugin,我们需要配置这个插件来实现我们的需求,首先看看这个插件的配置项:

    字段名类型含义
    namestring必传值,即输出的模块名,被远程引用时路径为${name}/${expose}libraryobject声明全局变量的方式,name为umd的namefilenamestring构建输出的文件名remotesobject远程引用的应用名及其别名的映射,使用时以key值作为nameexposesobject被远程引用时可暴露的资源路径及其别名sharedobject与其他应用之间可以共享的第三方依赖,使你的代码中不用重复加载同一份依赖

    有了这些我们就能进行实战演习了:

    组件提供方

    我们想要使用一个远程组件,那么我们就需要配置一下这个远程组件的提供方。

    
    const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')
    
    module.exports = {
        plugins: [
            new ModuleFederationPlugin({
                name: 'vue2Project',
                filename: 'hello-world.js',
                exposes: {
                    './HelloWorld': path.resolve(projectDir, './src/components/HelloWorld'),
                    './ant-design-vue': path.resolve(projectDir, './src/utils/ant-design-vue')
                },
                shared: [vue,'ant-design-vue']
            })
        ]
    }
    

    首先定义namefilename属性,这两个很关键,在使用远程组件的时候会被用到。

    exposes

    故名思意,就是暴露给外部使用的意思。笔者这里暴露了两个组件,一个是一个常规的HelloWorld.vue组件,另一个是ant-design-vue第三方包,也就是我们前面演示的使用远程第三方包的服务提供方,这里不用纠结这个src/utils/ant-design-vue里是啥,给你就是一个普通的js文件然后将ant-design-vue导入并暴露了一下:

    // src/utils/ant-design-vue
    export * from 'ant-design-vue';
    

    这里稍微解读一下这个exposes对象里的用法:

    • 属性名:一般使用./xxx,定义我们暴露出这个组件的名字,然后在使用的时候就能直接写变量名/xxx了,也就是前面演示导入方式。

      • import { Card } from '@v2hw/ant-design-vue'
        
    • 属性值:表示这个组件的实际位置。

    shared

    这个属性也很有意思,就是我们在使用远程组件的时候,这个组件如果依赖了某个第三方包,那么它就会首先从使用方的shared中查找,如果查找到之后,就会直接使用当前项目的,否则则从该远程组件的提供方进行获取。

    组件使用方

    当我们的提供方项目启动之后,我们就可以轻松在另一个项目中进行使用了:

    入口文件修改

    对于一般的项目来说,我们的入口文件都是src目录下index.jsmain.js其他的也同理,同时这个入口文件中做了对相关框架或库的一个初始化工作,比如下面这个Vue项目的入口文件:

    import Vue from 'vue'
    import App from './App.vue'
    
    Vue.config.productionTip = false
    
    new Vue({
      render: h => h(App),
    }).$mount('#app')
    
    

    这里是一个最基础版的实例化Vue的过程,那么我们在使用module federation中需要怎么修改呢?

    其实也很简单,在同级目录下(不同级也行,看你喜好),新建一个bootstrap.js,然后把原来入口文件中的内容全部转移到这个文件中去,接着在原来的入口文件中导入并执行这个文件:

    // index.js或main.js
    import('./bootstrap.js')
    

    这样就可以继续往下走啦。

    那么为什么要这么做呢,这其实是module federation的一个依赖前置的概念,如果是同步执行,那么就无法保证在启动项目的时候已经准备好了依赖模块,所以这里采用import的方式实现异步,然后再由webpack帮我们去分析依赖,并等待前置依赖加载好之后再执行bootstrap中的相关内容启动项目。

    webpack配置
    const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')
    
    module.exports = {
        plugins: [
            new ModuleFederationPlugin({
                name: 'vue2TwoProject',
                filename: 'hello-world.js',
                exposes: {
                    './HelloWorld': path.resolve(projectDir, './src/components/HelloWorld')
                },
                remotes: {
                    '@v2hw': 'vue2Project@http://localhost:5001/hello-world.js'
                },
                shared: {
                    vue: {
                        import: "vue",
                        shareKey: "vue",
                        shareScope: "default",
                        singleton: true
                    }
                }
            })
        ]
    }
    

    其他几个属性也就不多提了,上面也介绍完了,这里主要关注一下这个remotes

    remotes

    用于配置远程项目的引用,属性名表示这个引用的别名(全局唯一值),属性值由几个部分组成:{name}@{url}/{filename}

    • name: 远程项目的名字,也就是远程组件提供方ModuleFederationPlugin配置中的name字段。
    • url: 远程项目的地址
    • filename:远程组件提供方ModuleFederationPlugin配置中的filename字段。

    接下来就能直接在项目中使用了:

    import { Card } from '@v2hw/ant-design-vue'
    

    是不是一下子思绪就清晰了,这个@v2hw远程项目暴露了这个ant-design-vue属性,所以我们就可以直接这种方式进行获取了。

    除了上述这种配置方式以外,你也可以借助library配置的方式来使用远程组件,区别就是我们需要在项目中引入远程项目的js文件,笔者还是比较喜欢这种直接配置方式,不太喜欢再多此一举引入script标签,这里就不进行演示了,有兴趣的可以去官网查找。

    奇淫技巧

    既然我们的项目具备了使用其他项目远程组件的能力,那是不是...(嘿嘿嘿)可以尝试一下在不同框架间互用组件呢?比如Vue3使用Vue2组件,或者Vue中使用React项目的组件?

    Vue3使用Vue2组件

    通过前面的讲述,相信大家对远程组件的导入应该也有了一定的认识,在同版本的Vue项目中,我们可以将组件导入之后直接进行注册使用,而对于不同版本的Vue组件来说,我们需要做一个适配器的能力,也就是把不兼容的组件变成兼容的,这里来演示一下如何使Vue2组件能够在Vue3中使用:

    <template>
      <div id="vue2HW"></div>
      <Vue2HelloWorld></Vue2HelloWorld>
    </template>
    
    <script>
    import { vue2ToVue3 } from './utils'
    import HelloWorld2 from '@v2hw/HelloWorld'
    console.log(HelloWorld2);
    
    export default {
      name: 'App',
      components: {
        Vue2HelloWorld: vue2ToVue3(HelloWorld2, 'vue2HW')
      }
    }
    </script>
    

    从代码中看,与之前Vue2项目使用Vue2远程组件不一样的是,这里在注册的同时调用了一个方法,从这个方法的名字上来看我们能大概知道这是一个将Vue2组件转化为Vue3能识别的结构的方法,接下来我们来看看这个方法具体做了哪些事。

    Vue适配器

    在研究详细代码之前,我们先了解一下我们上述导入的组件数据究竟长成啥样(注意上面代码的console.log):

    深入探索Webpack5之Module Federation的“奇淫技巧”

    没错,就是我们熟悉的Vue2 Options写法的组件,是不是很清晰了,接下来我们来研究怎么转换:

    import Vue2 from './vue2';
    
    export function vue2ToVue3(WrapperComponent, wrapperId) {
        let vm;
        return {
            mounted() {
                vm = new Vue2({
                    render: createElement => {
                        return createElement(
                            WrapperComponent,
                            {
                                on: this.$attrs, // Vue3把Vue2的`$listeners`与`$attrs`合并到`$attrs`上去了
                                attrs: this.$attrs,
                                props: this.$props,
                                scopedSlots: this.$scopedSlots
                            }
                        )
                    }
                });
                vm.$mount(`#${wrapperId}`)
            },
            props: WrapperComponent.props,
            render() {
                vm && vm.$forceUpdate();
            }
        }
    }
    

    我们需要知道的是,对于我们Vue组件模板来说,最终都会被编译成一个render函数提供渲染能力,故,我们需要将我们从远程获取到的Vue2组件数据变成Vue3组件兼容的结构,保证Vue3能够正确的解析该组件。

    从代码中看,我们return了一个包含mountedpropsrender三个属性的对象,相信大家对这三个属性也不太陌生(如果有不熟悉render属性的可以参考Vue官网jsx部分),组件进行每次更新和渲染时,都会调用render方法,所以这里直接对我们创建的vue2实例进行更新即可。第一步先定义一个vm变量,用于后续接收实例化后的Vue对象,然后让我们看到这个mounted函数,接下来重点来关注这个函数所做的事。

    我们这里导入一个一个Vue2版本的包,然后使用这个包对传入的Vue2组件进行渲染,同样内部采用render函数的方式,调用createElement方法执行渲染,并对需要传入该组件的属性进行传递,由于Vue3Vue2的特殊性,on属性传入的也是Vue3$attrs属性。

    然后渲染完成之后,再手动调用$mount进行挂载,挂载对象为传入的dom上的id

    就这样,一个简单的Vue2Vue3就完成了,是不是也十分简单呢。

    动态引入

    前面我们在使用远程组件的时候采用了配置化的方式,这种方式在有些需要灵活动态引入的时候就显得不够方便了,接下来演示一下动态组件导入的方式:

    <template>
      <div id="vue2Remote"></div>
      <dynamicHelloWorld></dynamicHelloWorld>
    </template>
    
    <script>
    import { defineAsyncComponent } from 'vue'
    import { vue2ToVue3, loadRemoteComponent } from './utils'
    
    export default {
      name: 'App',
      components: {
        dynamicHelloWorld: defineAsyncComponent(async () => {
            const component = await loadRemoteComponent({
                url: 'http://localhost:5001/hello-world.js',
                scope: 'vue2Project',
                module: './HelloWorld'
            });
            return vue2ToVue3(component.default, 'vue2Remote');
        })
      }
    }
    </script>
    

    这里使用了Vue3defineAsyncComponent注册异步组件的方法,因为动态获取远程组件会有一个请求远程组件的过程,所以是异步的。

    同时这里核心部分就是这个loadRemoteComponent方法,它接受三个参数:

    • url:远程项目地址。
    • scope:远程项目的名字。
    • module:远程项目中的指定模块。

    然后我们来研究下这个loadRemoteComponent中又是怎么实现的吧:

    export async function loadRemoteComponent(config) {
        return loadScript(config).then(() => loadComponentByWebpack(config))
    }
    
    function loadScript(config) {
        return new Promise((resolve, reject) => {
            const script = document.createElement('script');
            script.src = config.url
            script.type = 'text/javascript'
            script.async = true
            script.onload = () => {
                console.log(`Dynamic Script Loaded: ${config.url}`)
                document.head.removeChild(script);
                resolve();
            }
            script.onerror = () => {
                console.error(`Dynamic Script Error: ${config.url}`)
                document.head.removeChild(script)
                reject()
            }
            document.head.appendChild(script)
        })
    }
    
    async function loadComponentByWebpack({ scope, module }) {
        // 初始化共享作用域,这将使用此构建和所有远程提供的已知模块填充它
        await __webpack_init_sharing__('default')
        const container = window[scope] // 获取容器
        // 初始化容器,它可以提供共享模块
        await container.init(__webpack_share_scopes__.default);
        const factory = await window[scope].get(module);
        return factory();
    }
    

    我们先观察到这个loadRemoteComponent方法,它内部return了获取到的远程组件的数据,往下看这个loadScript方法,它返回了一个Promise,内部的代码我们稍微瞄一眼就知道这里是在使用js动态创建script标签的方式来加载一个远程js文件,当加载完毕时,将这个标签从页面中移除,然后结束。

    js文件加载完毕之后,页面中就拿到了远程项目暴露的组件信息,这个时候,我们就能使用 loadComponentByWebpack来加载指定的组件了,这个函数中主要就是初始化远程组件所需的环境,并根据我们传入的module,从相关作用域中查到到对应的模块进行返回。

    React中引入Vue2组件

    同理,这个过程也需要一个Adapter,这里笔者就没有自己写一个了,直接采用第三方包vuera来实现这个效果了。

    import React, { useState } from "react";
    import { VueWrapper } from "vuera";
    import VueHelloWorld from "@v2hw/HelloWorld";
    import styled from 'styled-components'
    import { Card } from "antd";
    const { Meta } = Card;
    
    const AppDiv = styled.div`
      display: flex;
      justify-content: space-around;
    `
    
    const ReactContainer = styled.div`
      margin: 10px;
      display: inline-block;
      width: 240px;
      height: 400px;
    `
    
    function App() {
    	return (
    		<AppDiv>
    			<ReactContainer>
    				<Card
    					hoverable
    					style={{ width: 240 }}
    					cover={
    						<img
    							
    							src="https://os.alipayobjects.com/rmsportal/QBnOOoLaAfKPirc.png"
    						/>
    					}
    				>
    					<Meta
    						
    						description="我是React组件"
    					/>
    				</Card>
    			</ReactContainer>
    			<VueWrapper component={VueHelloWorld}></VueWrapper>
    		</AppDiv>
    	);
    }
    
    export default App;
    
    

    看看效果

    深入探索Webpack5之Module Federation的“奇淫技巧”

    原理剖析

    项目源码地址

    首先克隆项目到本地,进入module-federation目录下(这里将以此目录中vue2vue2-two两个子项目进行演示),然后执行构建命令(详见package.json)。

    1. npm run build:vue2:devyarn build:vue2:dev
    2. npm run build:vue2:two:devyarn build:vue2:two:dev

    执行完以上命令之后,我们就拿到了这两个子项目的构建结果(结果输出到dist目录下对应目录),我们给这两个项目指代一个名字:

    • 项目A:vue2
    • 项目B:vue2-two

    依赖关系为:项目B采用module federation使用了项目A暴露出来的一个远程组件,故A是提供者,B是消费者。

    首先来看看项目B也就是消费者构建的产物,对于远程组件或库的使用部分源码(为了方便理解,笔者这里删除了一些复杂代码):

    // dist/vue2-two/hello-world.js
    
    // 依赖的chunk和其内部依赖关系,这里表示我们使用的ant-design-vue来自webpack/container/remote/@v2hw/ant-design-vue
    var chunkMapping = {
    	"webpack_container_remote_v2hw_ant-design-vue": [
    		"webpack/container/remote/@v2hw/ant-design-vue"
    	]
    };
    // 对应模块的所有依赖
    var idToExternalAndNameMapping = {
    	"webpack/container/remote/@v2hw/ant-design-vue": [
    		"default",
    		"./ant-design-vue",
    		"webpack/container/reference/@v2hw"
    	]
    };
    __webpack_require__.f.remotes = (chunkId, promises) => {
        // 判断当前需要加载的chunk是否在module-federation配置项中的remotes中声明,也就是是否存在于上面的chunkMapping中
    	if(__webpack_require__.o(chunkMapping, chunkId)) {
            // 根据当前加载模块在chunkMapping中的映射,找到该chunk依赖的其他模块
    		chunkMapping[chunkId].forEach((id) => {
    
                // 根据所需要加载的模块在idToExternalAndNameMapping中的映射,找到所需要的其他依赖
    			var data = idToExternalAndNameMapping[id];
    
    			var onFactory = (factory) => {
    				data.p = 1;
    				__webpack_modules__[id] = (module) => {
    					module.exports = factory(); // 返回加载完的模块内容
    				}
    			};
    		});
    	}
    }
    

    结合源码的注释来看,我们可以发现它的整个流程大致是:

    导入一个远程模块 => 获取该模块对应的实际来源 => 通过该模块id获取其所有依赖项 => 得到最终结果

    总结

    通过这一番介绍,相信大家也对Module Federation这项技术有了一个大致的了解,如果说有兴趣的话,可以再深入挖掘挖掘它的其他有意思的玩法,笔者在探索的过程当中感受到这项技术的潜力,在未来,或许它将会是微前端的终极解决方案,一个天然支持远程组件调用的方案。

    参考链接

    探索 webpack5 新特性 Module federation 在腾讯文档的应用

    官方文档

    YY团队emp

    一起来看一看Webpack Module Federation

    三大应用场景调研,Webpack 新功能 Module Federation 深入解析


    起源地下载网 » 深入探索Webpack5之Module Federation的“奇淫技巧”

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元