找回密码
 会员注册
查看: 25|回复: 0

探探各个微前端框架

[复制链接]

9

主题

0

回帖

28

积分

新手上路

积分
28
发表于 2024-10-12 00:35:23 | 显示全部楼层 |阅读模式
本文作者为 360 奇舞团前端开发工程师微前端架构是为了在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。微前端框架内的各个应用都支持独立开发部署、不限技术框架、支持独立运行、应用状态隔离但也可共享等特征。本文会从框架的应用隔离实现方案、实战、优缺点三个方面探一探各个框架。帮助大家了解各个框架是如何使用,如何运行,从而能选出适合自己项目的微前端方案。iframe在没有各大微前端解决方案之前,iframe是解决这类问题的不二之选,因为iframe提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题:url 不同步,浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。UI 不同步,DOM 结构不共享,弹窗只能在iframe内部展示,无法覆盖全局全局上下文完全隔离,内存变量不共享,iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。慢,每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。single-spa目前(2024年4月)github star 13kSingle-spa(https://github.com/single-spa/single-spa) 是最早的微前端框架,兼容多种前端技术栈;是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架;简单来说就是一个聚合,使用这个库可以让你的应用可以 使用多个不同的技术栈(vue、react、angular等等)进行同步开发,最后使用一个公用的路由去实现完美的切换;实现方案Single-spa实现了一套生命周期,开发者需要在相应的时机自己去加载对应的子应用。它做的事情就是注册子应用、监听 URL 变化,然后加载对应的子应用js,执行对应子应用的生命周期流程。提供registerApplication方法,用来注册子应用列表。提供了activeWhen,由开发者指定路由满足条件时,激活(挂载)子应用的js、css。js隔离由single-spa-leaked-globals实现,本质上就是在 mount A 子应用时,正常添加全局变量,比如 jQuery 的 $, lodash 的 _。在 unmount A 子应用时,用一个对象记录之前给 window 添加的全局变量,并把 A 应用里添加 window 的变量都删掉。下一次再 mount A 应用时,把记录的全局变量重新加回来就好了。css隔离:子应用和子应用之间通过single-spa-css插件提供的css生命周期函数,做到子应用mount时加载css,子应用unmount时将css也unmount掉;而主应用与子应用之间可以通过PostCSSPrefix Selector 给样式自动加前缀的方式,或者Shadow DOM的形式去解决。single-spa实战1. 主应用入口文件:主要通过single-spa提供的registerApplication方法注册子应用,子应用需要指定加载子应用的方法、和路由条件。importVuefrom'vue'importAppfrom'./App.vue'importrouterfrom'./router'import{registerApplication,start}from'single-spa'Vue.config.productionTip=false//远程加载子应用functioncreateScript(url){returnnewPromise((resolve,reject)=>{constscript=document.createElement('script')script.src=urlscript.onload=resolvescript.onerror=rejectconstfirstScript=document.getElementsByTagName('script')[0]firstScript.parentNode.insertBefore(script,firstScript)})}//记载函数,返回一个promisefunctionloadApp(url,globalVar){//支持远程加载子应用returnasync()=>{awaitcreateScript(url+'/js/chunk-vendors.js')awaitcreateScript(url+'/js/app.js')//这里的return很重要,需要从这个全局对象中拿到子应用暴露出来的生命周期函数returnwindow[globalVar]}}//子应用列表constapps=[{//子应用名称name:'app1',//子应用加载函数,是一个promiseapp:loadApp('http://localhost:8081','app1'),//当路由满足条件时(返回true),激活(挂载)子应用activeWhen:location=>location.pathname.startsWith('/app1'),//传递给子应用的对象customProps:{}},{name:'app2',app:loadApp('http://localhost:8082','app2'),activeWhen:location=>location.pathname.startsWith('/app2'),customProps:{}},{//子应用名称name:'app3',//子应用加载函数,是一个promiseapp:loadApp('http://localhost:3000','app3'),//当路由满足条件时(返回true),激活(挂载)子应用activeWhen:location=>location.pathname.startsWith('/app3'),//传递给子应用的对象,这个很重要,该配置告诉react子应用自己的容器元素是什么,这块儿和vue子应用的集成不一样,官网并没有说这部分,或者我没找到,是通过看single-spa-react源码知道的customProps:{domElement:document.getElementById('microApp'),//添加name属性是为了兼容自己写的lyn-single-spa,原生的不需要,当然加了也不影响name:'app3'}}]//注册子应用for(leti=apps.length-1;i>=0;i--){registerApplication(apps[i])}newVue({router,mounted(){//启动start()},render:h=>h(App)}).$mount('#app')2. 子应用导出文件子应用需要安装single-spa-react或者single-spa-vue,将子应用传递给single-spa-react,得到子应用运行的生命周期,子应用将生命周期导出到全局,在主应用可以获取子应用的生命周期函数importReactfrom'react';importReactDOMfrom'react-dom';import'./index.css'import{BrowserRouter,Link,Route}from'react-router-dom'importsingleSpaReactfrom'single-spa-react'//子应用独立运行if(!window.singleSpaNavigate){ReactDOM.render(rootComponent(),document.getElementById('root'))}//生命周期aconstreactLifecycles=singleSpaReact({React,ReactDOM,rootComponent,errorBoundary(err,info,props){returnThisrenderswhenacatastrophicerroroccurs}})//这里和vue不一样,props必须向下传递exportconstbootstrap=asyncprops=>{console.log('app3bootstrap');returnreactLifecycles.bootstrap(props)}exportconstmount=asyncprops=>{console.log('app3mount');returnreactLifecycles.mount(props);}exportconstunmount=asyncprops=>{console.log('app3unmount');returnreactLifecycles.unmount(props)}//根组件functionrootComponent(){return Home|About}//home组件functionHome(){return app3homepage }//about组件functionAbout(){return app3aboutpage }3. 打包配置将子应用导出模式设置为umdconstpackage=require('./package.json')module.exports={//告诉子应用在这个地址加载静态资源,否则会去基座应用的域名下加载publicPath:'//localhost:8082',//开发服务器devServer:{port:8082},configureWebpack:{//导出umd格式的包,在全局对象上挂载属性package.name,基座应用需要通过这个全局对象获取一些信息,比如子应用导出的生命周期函数output:{//library的值在所有子应用中需要唯一library:package.name,libraryTarget:'umd'}}}4. 预览可以看到它是动态加载的子应用的js,并执行js,将内容渲染到了主应用的盒子内。框架优缺点优点:敏捷性 - 独立开发、独立部署,微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新;技术栈无关,主框架不限制接入应用的技术栈,微应用具备完全自主权;增量升级,在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略缺点需要自己去加载子应用不支持 Javascript 沙箱隔离,需要自己去使用single-spa-leaked-globals之类的库去隔离不支持css隔离,需要自己使用single-spa-css库或者postcss等去解决样式冲突问题无法预加载qiankun目前(2024年4月) github star 15.4k阿里的qiankun是一个基于single-spa的微前端实现库,孵化自蚂蚁金融,帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。实现方案single-spa是基于js-entry方案,而qiankun是基于html-entry及沙箱设计,使得微应用的接入像使用 iframe 一样简单。主应用监听路由,加载对应子应用的html,挂载到主应用的元素内,然后解析子应用的html,从中分析出css、js再去沙盒化后加载执行,最终将子应用的内容渲染出来。qiankun实现样式隔离有两种模式可供开发者选择:strictStyleIsolation:这种模式下 qiankun 会为每个微应用的容器包裹上一个shadow dom节点,从而确保微应用的样式不会对全局造成影响。experimentalStyleIsolation:当experimentalStyleIsolation被设置为 true 时,qiankun 会改写子应用所添加的样式,会为所有样式规则增加一个特殊的选择器规则,来限定其影响范围qiankun实现js隔离,采用了两种沙箱,分别为基于Proxy实现的沙箱和快照沙箱,当浏览器不支持Proxy会降级为快照沙箱Proxy沙箱机制://伪代码classProxySandbox{constructor(){constrawWindow=window;constfakeWindow={}constproxy=newProxy(fakeWindow,{set(target,p,value){target[p]=value;returntrue},get(target,p){returntarget[p]||rawWindow[p];}});this.proxy=proxy}}letsandbox1=newProxySandbox();letsandbox2=newProxySandbox();window.a=1;//伪代码((window)=>{window.a='hello';console.log(window.a)//hello})(sandbox1.proxy);((window)=>{window.a='world';console.log(window.a)//world})(sandbox2.proxy);快照沙箱//伪代码classSnapshotSandbox{constructor(){this.proxy=window;this.modifyPropsMap={};//修改了那些属性this.active();//调用active保存主应用window快照}/**1.初始化时,在子应用即将mount前,先调用active,保存当前主应用的window快照*/active(){this.windowSnapshot={};//window对象的快照for(constpropinwindow){if(window.hasOwnProperty(prop)){//将window上的属性进行拍照this.windowSnapshot[prop]=window[prop];}}Object.keys(this.modifyPropsMap).forEach(p=>{window[p]=this.modifyPropsMap[p];});}/***子应用卸载时,遍历当前子应用的window属性,和主应用的window快照做对比*如果不一致,做两步操作*1.保存不一致的window属性,*2.还原window*/inactive(){for(constpropinwindow){//diff差异if(window.hasOwnProperty(prop)){//将上次拍照的结果和本次window属性做对比if(window[prop]!==this.windowSnapshot[prop]){//保存修改后的结果this.modifyPropsMap[prop]=window[prop];//还原windowwindow[prop]=this.windowSnapshot[prop];}}}}}qiankun实战1. 主应用入口文件初始化主应用,并注册子应用主应用入口文件初始化应用,注册子应用,注册子应用时支持传入子应用列表, 注册子应用时需要指明以下几个主要参数:name: 微应用的名称,微应用之间必须确保唯一entry: 子应用的访问链接。主应用会加载整个页面,例如https://qiankun.umijs.org/guide/container:需要挂载子应用的DOM元素loader: 子应用未加载时的界面,一般为loadingactiveRule: 路由匹配规则开启子应用start(options)options.prefetch此时可以选择是否预加载子应用。options.sandbox默认情况下的沙箱可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离。qiankun提供了另外两种方式的隔离,供开发者选择:strictStyleIsolation: 当配置为{ strictStyleIsolation: true }时表示开启严格的样式隔离模式。这种模式下 qiankun 会为每个微应用的容器包裹上一个shadow dom节点,从而确保微应用的样式不会对全局造成影响。experimentalStyleIsolation:当{experimentalStyleIsolation: true}被设置,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围。import{registerMicroApps,start,initGlobalState}from'qiankun';registerMicroApps([{name:'reactapp',//appnameregisteredentry:'//localhost:7100',container:'#yourContainer',activeRule:'/yourActiveRule',},{name:'vueapp',entry:{scripts:['//localhost:7100/main.js']},container:'#yourContainer2',activeRule:'/yourActiveRule2',},]);//通讯const{onGlobalStateChange,setGlobalState}=initGlobalState({user:'qiankun',});onGlobalStateChange((value,prev)=>console.log('[onGlobalStateChange-master]:',value,prev));setGlobalState({ignore:'master',user:{name:'master',},});/***设置默认进入的子应用*/setDefaultMountApp('/react16');/***启动应用*/start({prefetch:true,//预加载子应用sandbox:{strictStyleIsolation:true,//shadowdom的方式实现样式隔离//experimentalStyleIsolation:true,//添加特殊的选择器的方式实现样式隔离}});runAfterFirstMounted(()=>{console.log('[MainApp]firstappmounted');});2. 子应用导出生命周期钩子子应用需要在自己的入口 js导出 bootstrap、mount、unmount 三个生命周期钩子,以供主应用在适当的时机调用。importReactfrom'react';importReactDOMfrom'react-dom';importAppfrom'./App';import*asserviceWorkerfrom'./serviceWorker';functionrender(props){const{container}=props;ReactDOM.render(,containercontainer.querySelector('#root'):document.querySelector('#root'));}/***和主应用通讯*/functionstoreTest(props){props.onGlobalStateChange((value,prev)=>console.log(`[onGlobalStateChange-${props.name}]:`,value,prev),true);props.setGlobalState({ignore:props.name,user:{name:props.name,},});}if(!window.__POWERED_BY_QIANKUN__){render({});}/***bootstrap只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用mount钩子,不会再重复触发bootstrap。*通常我们可以在这里做一些全局变量的初始化,比如不会在unmount阶段被销毁的应用级别的缓存等。*/exportasyncfunctionbootstrap(){console.log('[react16]reactappbootstraped');}/***应用每次进入都会调用mount方法,通常我们在这里触发应用的渲染方法*/exportasyncfunctionmount(props){console.log('[react16]propsfrommainframework',props);storeTest(props);render(props);}/***应用每次切出/卸载会调用的方法,通常在这里我们会卸载微应用的应用实例*/exportasyncfunctionunmount(props){const{container}=props;ReactDOM.unmountComponentAtNode(containercontainer.querySelector('#root'):document.querySelector('#root'));}3. 配置打包工具:为了让主应用能正确识别微应用暴露出来的一些全局信息和开发环境下的跨域兼容,在子应用(以create-react-app出来的react项目为例)安装@rescripts/cli,并在子应用目录下新建.rescriptsrc.js,内容如下:const{name}=require('./package');module.exports={webpackconfig)=>{config.output.library=`${name}-[name]`;config.output.libraryTarget='umd';//为了能通过window['app-name1']拿到子应用声明的生命周期//webpack5需要把jsonpFunction替换成chunkLoadingGlobalconfig.output.jsonpFunction=`webpackJsonp_${name}`;config.output.globalObject='window';returnconfig;},devServer_)=>{constconfig=_;config.headers={'Access-Control-Allow-Origin':'*',};config.historyApiFallback=true;config.hot=false;config.watchContentBase=false;config.liveReload=false;returnconfig;},};4. 预览使用strictStyleIsolation:true方式进行样式隔离,会生成一个shadow dom,进行样式的完全隔离:使用experimentalStyleIsolation:true的方式进行样式隔离,会在css选择器前添加特殊标识:可以看到,qiankun会将子应用的html渲染到自定义的container中。 主应用加载的是子应用的html,在解析子应用的html的过程中遇到js和css会载框架内进行沙盒处理,完成css和js的隔离,之后下载并执行,完成整个子应用的渲染过程。框架优缺点优点html entry的接入方式,不需要自己写load方法,而是直接写子应用的访问链接就可以。提供js沙箱提供样式隔离,两种方式可选资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。社区活跃umi 插件,提供了@umijs/plugin-qiankun供umi应用一键切换成微前端架构系统 除了最后一点拓展以外,微前端想要达到的效果都已经达到。应用间通信简单,全局注入路由保持,浏览器刷新、前进、后退,都可以作用到子应用缺点改造成本较大,从 webpack、代码、路由等等都要做一系列的适配对 eval 的争议,eval函数的安全和性能是有一些争议的:MDN的eval介绍;无法同时激活多个子应用,也不支持子应用保活无法支持vite等 ESM 脚本运行wujie目前(2024年4月)github star 3.7kwujie是腾讯出品。基于webcomponent容器 +iframe沙箱,能够完善的解决适配成本、样式隔离、运行性能、页面白屏、子应用通信、子应用保活、多应用激活、vite 框架支持、应用共享等实现方案无界利用iframe和webcomponent来搭建天然的js隔离沙箱和css隔离沙箱,利用iframe的history和主应用的history在同一个top-level browsing context来搭建天然的路由同步机制支持以fiber的形式执行js,由于子应用的执行会阻塞主应用的渲染线程,当fiber设置为true,那么js执行时采取类似react fiber的模式方式间断执行,每个 js 文件的执行都包裹在requestidlecallback中,每执行一个js可以返回响应外部的输入,但是这个颗粒度是js文件,如果子应用单个js文件过大,可以通过拆包的方式降低达到fiber模式效益最大化wujie是如何渲染子应用的?wujie跟qiankun一样,都是基于html entry加载的,但他们解析html的过程是不一样的。 qiankun是直接解析并执行js、css、html的,而wujie则是先解析html,提取出script脚本放入空的iframe中,提取出css、html放入到web components中,具体来说:解析入口 HTML,分别得到script、css、模版html创建一个纯净的 iframe,为了实现应用间(iframe 间)通讯,无界子应用iframe 的 url 会设置为主应用的域名(同域),因此 iframe 的 location.href 并不是子应用的 url。创建好后停止加载iframe。iframe内插入js,将抽离出来的script脚本,插到iframe中去,在iframe中执行子应用的js创建web component,id为子应用id,将抽离出来的html插入。由于iframe内的js有可能操作dom,但是iframe内没有dom,随意wujie框架内对iframe拦截document对象,统一将dom指向shadowRoot,此时比如新建元素、弹窗或者冒泡组件就可以正常约束在shadowRoot内部。wujie实战wujie接入很简单,主应用可以让开发者以组件的方式加载子应用。子应用只需要做支持跨域请求改造,这个是所有微前端框架运行的前提,除此之外子应用可以不做任何改造就可以在无界框架中运行,不过此时运行的方式是重建模式。 子应用也可以配置保活、生命周期适配进入保活模式或单例模式。1. 主应用入口文件与其他框架一样,先配置子应用,//main-react/index.jsimport"react-app-polyfill/stable";import"react-app-polyfill/ie11";importReactfrom"react";importReactDOMfrom"react-dom";importWujieReactfrom"wujie-react";import"./index.css";importAppfrom"./App";importhostMapfrom"./hostMap";importcredentialsFetchfrom"./fetch";importlifecyclesfrom"./lifecycle";importpluginsfrom"./plugin";const{setupApp,preloadApp,bus}=WujieReact;constisProduction=process.env.NODE_ENV==="production";bus.$on("click",(msg)=>window.alert(msg));constdegrade=window.localStorage.getItem("degrade")==="true"||!window.Proxy||!window.CustomElementRegistry;/***大部分业务无需设置attrs*此处修正iframe的src,是防止githubpagescsp报错*因为默认是只有host+port,没有携带路径*/constattrs=isProduction{src:hostMap("//localhost:7700/")}:{};/***配置应用,主要是设置默认配置*preloadApp、startApp的配置会基于这个配置做覆盖*/setupApp({name:"react16",url:hostMap("//localhost:7600/"),attrs,//子应用iframe的srcexec:true,//预执行fetch:credentialsFetch,//自定义的fetch方法plugins,/**子应用短路径替换,路由同步时生效*/prefix:{"prefix-dialog":"/dialog","prefix-location":"/location"},/**子应用采用降级iframe方案*/degrade,...lifecycles,});setupApp({name:"vue3",url:hostMap("//localhost:7300/"),attrs,exec:true,alive:true,//子应用保活,state不会丢失plugins:[{cssExcludes:["https://stackpath.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"]}],//引入了的第三方样式不需要添加credentialsfetchurl,options)=>url.includes(hostMap("//localhost:7300/"))credentialsFetch(url,options):window.fetch(url,options),degrade,...lifecycles,});if(window.localStorage.getItem("preload")!=="false"){preloadApp({name:"react16",});if(window.Proxy){preloadApp({name:"vue3",});}}ReactDOM.render(,document.getElementById("root"));引入子应用的地方直接以组件式的方式引入:importReactfrom"react";importhostMapfrom"../hostMap";importWujieReactfrom"wujie-react";import{useNavigate,useLocation}from"react-router-dom";exportdefaultfunctionReact16(){constnavigation=useNavigate();constlocation=useLocation();constpath=location.pathname.replace("/react16-sub","").replace("/react16","").replace("/","");////constreact16Url=hostMap("//localhost:7600/")+path;constprops={jumpname)=>{navigation(`/${name}`);},};return(//单例模式,name相同则复用一个无界实例,改变url则子应用重新渲染实例到对应路由);}2. 预览框架优缺点优点接入简单,可以以组件的方式引入子应用纯净无污染无界利用iframe和webcomponent来搭建天然的js隔离沙箱和css隔离沙箱利用iframe的history和主应用的history在同一个top-level browsing context来搭建天然的路由同步机制副作用局限在沙箱内部,子应用切换无需任何清理工作,没有额外的切换成本支持viteesmoudle加载,由于js是独立在iframe中加载的,所以支持esmodule加载支持预加载支持应用保活,子应用状态保留,由于是独立在iframe中的,而切换应用时不会移除iframe,所以子应用的状态会被保留在原来的iframe中,当主应用再次渲染子应用dom时,会显示之前的状态。多应用同时激活在线缺点iframe沙箱的src设置了主应用的host,初始化iframe的时候需要等待iframe的location.orign从'about:blank'初始化为主应用的host,这个采用的计时器去等待的不是很优雅。Micro App截至目前(2024年4月)github star 5.2kmirco-app是京东2021年开源的一款微前端框架。它借助了浏览器对webComponent的支持,实现了一套微前端方案体系。并且由于Shadow Dom对react这类库的兼容性较差,便自己实现了类Shadow Dom的效果。与qiankun相比,接入更加简单。最新的版本也支持iframe实现js隔离,类似wujie。实现方案首先micro-app实现了一个基于WebComponent的组件,并实现了类Shadow Dom的效果,开发者只需要用来加载子应用,整个对子应用的加载、js隔离、css隔离的逻辑都封装在了web component组件中,具体来说:当调用microApp.start()后,会注册一个名为micro-app的自定义webComponent标签。我们可以从中拿到子应用的线上入口地址。组件内部,当匹配到路由后,跟qiankun一样加载html,得到html字符串模版分析html字符串,提取头和,并替换为框架自定义标签和在内,会对script标签和link标签的内容进行加载并执行将和插入到标签内内提供了js沙箱方法(v1.0以前跟qiankun沙箱一样),挂载到后,内部会逐一对内的script标签的js绑定作用域,实现js隔离。css隔离方案默认使用正则将CSS字符串切割成最小单元,每个单元包含一段CSS信息,将所有的信息整理生成CSSTree,遍历CSSTree的每个规则,添加前缀实现样式隔离。js隔离方案micro-app有两种方式实现js隔离,默认是跟qiankun一样采用proxy沙箱的方式隔离, 在v1.0发布后支持了基于原生iframe的隔离方式。Micro App实战1. 主应用入口文件importReactfrom'react';importReactDOMfrom'react-dom';import'./index.css';importRouterfrom'./router';importmicroAppfrom'@micro-zoe/micro-app'microApp.start()ReactDOM.render(,document.getElementById('root'));调用子应用exportfunctionMyPage(){return( 子应用 //name:应用名称,url:应用地址)}2.预览框架优缺点优点接入简单,组件式引入子应用团队持续更新维护js隔离、css隔离、路由同步支持子应用保活, 需要开启keep-alive模式支持fiber模式,提升主应用的渲染性能。缺点1.0之前不支持vite,1.0之后支持了默认css隔离方式,主应用的样式还是会污染到子应用。子应用和主应用必须相同的路由模式,要么同时hash模式,要么同时history模式依赖于CustomElements和Proxy两个较新的API。Proxy暂时没有做兼容,所以对于不支持Proxy的浏览器无法运行micro-app。-END-关于奇舞团奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-27 00:26 , Processed in 0.465112 second(s), 27 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表