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

面向WebAssembly的ByteReact框架

[复制链接]

2万

主题

0

回帖

7万

积分

超级版主

积分
72898
发表于 2024-10-2 17:26:32 | 显示全部楼层 |阅读模式
1. 前言JavaScript 作为解析型脚本语言,它的运行速度,一直是其被诟病的一个点。WebAssembly 技术的出现,且被各主流浏览器所支持,让我们在 Web 应用中,使用新技术,突破 JavaScript 引擎的速度局限,有了新的可能。JavaScript 是动态类型语言,WebAssembly 是静态语言编译的产物,理论上 WebAssembly 的运行速度,能比 JavaScript 快好几倍。在本文中,我们将讨论如何对 Web 前端应用优化,从而提高框架以及业务代码的整体性能。2. 背景回顾 Web 前端框架的发展,从 jQuery 发展到如今的 React 和 Vue 等框架, 乃至最新的 Svelte 和 SolidJS,整个 Web 框架生态异常繁荣,而且运行速度也很快。但是,这些框架,再怎么发展,再怎么快,都无法突破 JavaScript 引擎的速度局限。而且,这些框架,只能在“框架自身耗时”上进行速度优化,无法在“业务代码耗时”上优化。这也是为什么笔者认为,尽管 SolidJS 比 React 快那么多,也仅是“框架部分耗时”快,总体上并没有快多少,没有太大实际意义。图 1. Web 应用耗时组成如上图 1 所示,Web 应用在渲染页面时的总耗时,一般可以分为 3 个部分:Dom 操作耗时,即 JavaScript 通过 Dom 相关 Api 直接操作 Dom 对象。比如插入对象,删除对象,更新对象属性等。可以说,这部分操作的耗时,是任意框架所必须的耗时,也是无法优化的耗时。框架耗时,这部分耗时,一般指框架的运行时耗时,不同框架差异基本就在这里,比如 React 的 diff 逻辑耗时就比较厉害,而 Svelte 直接就砍掉了运行时,其这部分耗时就非常短。业务代码耗时。一般来说,我们的业务代码,是运行在框架之上的,这部分耗时是不能忽视的,尤其对重运算的应用。而这部分,目前已有的前端框架,并没有办法在这部分进行速度提升。3. 整体设计思路要实现“业务代码耗时”提速,只能在引擎层次进行提速。WebAssembly 技术的出现,让我们可以突破 JavaScript 引擎的速度瓶颈,把“框架代码”和“业务代码”一起编译成 WebAssembly,放到 WebAssembly 引擎里运行。那整个应用的运行速度,理论上会有几倍的提升。总结来说,这里我们用到两种优化点:使用 WebAssembly 来替代 JavaScript ,从而提高 Web 代码运行速度。将业务代码编译成 WebAssembly ,业务代码本身的执行效率也能得到提升。下面,以 React 的一个简单示例,对整个渲染过程进行说明:functionFibonacci(n:number):number{if(n==1)return1;if(n==2)return2;returnFibonacci(n-1)+Fibonacci(n-2);}functionDemoComponent(props:{name:string,n:number}){letvalue=Fibonacci(props.n);return( Mynameis{props.name},thevalueofFibonacciwith{props.n}is:{value});}functionApp(){return}render(,document.getElementById('root'));以上代码,实际在运行时,包括下图所示各个流程。其中,React 执行 render 部分时,会执行业务代码。得出最后的 Dom 操作后,提交到 web 浏览器宿主进行最终的页面更新。图 2. 纯 JS 环境 React 运行流程在示例代码中,Fibonacci 函数是耗时比较多的业务代码,已有的 React 框架或其它任何框架,都没有任何办法提升这部分代码的运行速度。那假如我们使用 WebAssembly 技术,把所有非 dom 操作部分,都编译成 WebAssembly,然后放到 WebAssembly 引擎里去执行,是不是就能提升绝大部分的代码耗时呢?基本原理如下:图 3. WebAssembly 环境和 JS 环境下 React 运行流程基于此背景和设想,我们探索性地设计了一款基于 WebAssembly 的类 React 前端框架,名为 ByteReact。4. 技术选型前端开发人员现状目前,能支持编译成 WebAssembly 的语言,一般是 C++、Rust 、Go 等静态语言,JavaScript 这种如此灵活的语言,无法直接编译成 WebAssembly 。同时,由于前端的技术栈已经太复杂,学习曲线已经太陡峭了,如果要前端技术人员去学习一门新语言,并用这些低效率的静态语言开发应用,实践中是不大现实的。图 4. 前端开发者开发 WebAssembly 门槛高所幸的是,TypeScript 在 JavaScript 的超集竞争中脱颖而出,成为众多前端开发人员的首选语言。TypeScript 的优势及限制参考下面 JavaScript 代码:functionFibonacci(n){if(n==1)return1;if(n==2)return2;returnFibonacci(n-1)+Fibonacci(n-2);}在解析运行的时候,以上代码,参数 n 是不确定类型的,被封装成一个 JSValue。在执行到 “n - 1” 的运算时,需要先判断 n 的数据类型,如果 n 为数字,则进行减法运算,但如果 n 为 object 则返回 NaN。TypeScript 的出现,是为了在开发过程中,实现类型提示和类型约束。它虽然是按照 TypeScript 语法写,但最终输出结果是 JavaScript。我们可以利用 TypeScript 的类型信息,把相关代码当作强类型语言进行编译,生成 WebAssembly。如果把上面的示例代码改为 TypeScript,则是如下代码:functionFibonacci(n:number):number{if(n==1)return1;if(n==2)return2;returnFibonacci(n-1)+Fibonacci(n-2);}根据 TypeScript 的规范,参数 n 被标注为 number 类型,那在编译成 WebAssembly 时,可以直接把其当作数字类型,无需判断,因此运行效率会更高。可以说 TypeScript 的出现,前端开发语言,就可以是“强类型”的了。因此,尝试实现把 TypeScript 编译 WebAssembly,似乎是一条不错的路线。图 5. TypeScript 如何转换成 WebAssembly由于 TypeScript 一开始的定位就不是一门静态语言,而是 JavaScript 的超集,包含了大部分 JavaScript 动态语言的特性,比如 any 类型,原型链等。因此,TypeScript 并不能直接编译成 WebAssembly。我们的思路是参考 TypeScript 的语法,针对 WebAssembly 特性量身定做一门语言,并设计一款编译器生成 WebAssembly。TypeScript 编译成 WebAssembly 的方案目前,开源社区里这一尝试的代表是 AssemblyScript。它的语法与 TypeScript 类似,能直接编译成 WebAssembly。但它仅仅是语法与 TypeScript 类似,却本质上跟 TypeScript 是两码事,用起来很不方便,对 TypeScript 语法支持度很低,尤其是不支持闭包(Closure)。而 React 的 Hooks API 本质就是闭包,如果连闭包都不支持,根本就支持不了一个类 React 的框架。以下面调用 React 的 useCallback Api 作为例子:functionTestDemo(props:{name:string}){constonClick=useCallback(()=>{alert('Mynameis:'+props.name);});return(ClickMe)}每次渲染该控件,执行 TestDemo,都会生成一个闭包函数(即 useCallback 的参数),props 变量会封装到闭包函数的一个 context 对象,被保存起来。当用户点击按钮的时候,闭包函数就会被执行,并且读取 context 对象中的 props 对象的 name 值。这些闭包特性,是 React 框架所必需的。但目前 AssemblyScript 并不支持闭包,这与其 GC 策略有关。自己设计一个编译器 Bytets既然已有的开源项目不能满足需求,那就只能自己实现一个满足要求的编译器了。Bytets 因此而诞生,它是从笔者之前个人开源作品 wasmts 改造而来。它的核心原理,是把 TypeScript 编译成 C 代码,然后由 LLVM 编译成 WebAssembly。图 6. Bytets 把 TypeScript 编译成 WebAssembly 的原理在内存回收方面,Bytets 针对 JavaScript 和 WebAssembly 交互的情况,设计了一种特殊的内存回收策略。总体思路是,当 JavaScript 调用 WebAssembly 函数时,WebAssembly 代码是以单线程运行的。等 WebAssembly 代码运行完毕,控制权回到 JavaScript 层,此时 WebAssembly 层的栈是空的,在这个时机,对 WebAssembly 内存中所有的全局对象进行扫描(包括这些对象内部的子对象),得到需要保存的对象,其余对象则可以回收。这种方式,可以回收所有垃圾对象,确保不会内存泄露。当然,以后还可以增加引用计数的方式,对这一回收策略作为补充,及时回收垃圾对象。Bytets 目前支持闭包、type、interface、tuple 等 TypeScript 常用语法。有了 Bytets 之后,基于 WebAssembly 的 ByteReact 就有了可能。ByteReact 也是笔者个人开源项目 waos 改造而来,它参考了 React18 最新的思想,完全用 TypeScript 编写,支持 React18 所有核心功能,能通过 Bytets 把整个运行时编译成 WebAssembly 。5. 技术实现有了能把 TypeScript 编译成 WebAssembly 的编译器 Bytets,也有了整体设计思路,那具体如何实现呢?能把 React 的源码拿来改改就直接用吗?能做到历史已有的项目零成本迁移到基于 WebAssembly 的 ByteReact 吗?WebAssembly 中的代码,怎么对 Dom 进行操作?等等一系列问题需要思考。下面,我们以简略的方式,带大家体验一下从零构建 ByteReact。首先,可以肯定的是,并不能直接把 React 的源码拿来直接改一下就用,因为 React 源码涉及大量 JavaScript 的动态特性。比如很多类型不确定,很多 any,甚至用到原型链等。而且,如此庞大的代码,不是用 TypeScript 写的,要把它改成 TypeScript 也是很大的工程。所以,只能从零开始写一个可编译成 WebAssembly 的类 React 框架。我们以一个最简单的 React 进行举例。仅支持 render 、useCallback 接口,和 JSX 文件解析。完成如下示例代码:functionApp(props:{name:string}){constcallback=useCallback(()=>{alert("Iam"+props.name);},[props.name]);returnIam{props.name};}render(,document.getElementById('root'));首先,我们要解决的是 JSX 解析。我们知道,JSX 本质是一种语法糖,上面代码等价于如下代码:functionApp(props:{name:string}){constcallback=useCallback(()=>{alert("Iam"+props.name);},[props.name]);return_ByteReact_createHostComponent('div',{children:[_ByteReact_createHostComponent('button',{onClick:callback,children:`Iam${props.name}`})]});}render(,document.getElementById('root'));只要我们在 ByteReact 中,提供一个创建虚拟 dom 的接口函数(_ByteReact_createHostComponent),即可成功解析 JSX。以上的代码转换,由 Bytets 编译器进行转换。我们看一下,_ByteReact_createHostComponent 大致实现方式:exportfunction_ByteReact_createHostComponent(tagName:string,props:any){return{kind:1,//Component类型标记,tagName:tagName,props:props,hostId:_ByteReact_generateId()}asHostComponent;}这些代码,最终是要运行在 WebAssembly 上的,而 WebAssembly 并不能直接操作 Dom 对象,也不能把 Dom 对象放到 WebAssembly 中的虚拟 Dom 对象上。因此,我们需要一个 hostId 对真实的 Dom 对象进行映射,实现绑定。Tips:上面代码,涉及到 any 类型。在 Bytets 中,any 类型仅代表任意类型的 object ,不能把 number、string 等基本类型当作 any。当要取 any 类型对象的属性时,需要使用 Bytets 提供的接口,比如:let val:string = WSNative_getAttrString(obj, 'name');如果对象 obj 包含 属性 “name”,且其类型为 string ,则成功返回,否则,返回空。到这里,JSX 的解析基本没问题了,而且 Dom 对象的绑定也解决了。那代码中的 alert 和 document.getElementById ,这些代码是宿主环境函数,它们是如何调用的呢?Bytets 编译器支持自动生成胶水,对 JavaScript 宿主的常用函数进行自动绑定,相应的参数数据也会自动进行复制传输,我们不需要做特殊处理。整个过程,都是胶水自动处理的,用户是无感知的,那它底层实现是怎样的呢?我们先从代码的编译开始说明,上述的 alert 调用,会被 Bytets 编译成类似如下代码:/****1、WebAssembly跟宿主交互的参数,只能是数字类型,所以需要把字符串转化成指针传递到JS层*2、alert_01在生成的胶水代码中,会带有相应的目标宿主函数(即alert)和调用时的函数签名信息* 3、执行到这段代码时,会自动调用宿主的 runtime ,宿主 runtime 确定是 alert,同时函数签名第一个参数是string类型,则把其当作指针,读取 WebAssembly 内存的值。***/alert_01((number)(int32)__StringConcat_01("Iam",props->name));在编译过程中,会生成当前代码相关的一些元信息,用于 JS 层胶水代码做相关绑定。元信息大致如下:constWasmtsMeta={signature:["0*12"],require:[window.alert],/**省略其它属性**/};同时,上面的 alert_01((number)(int32)__StringConcat_01("I am ", props->name)), 实际是宏,展开后,代码如下:__host_call(0,0,(number)(int32)__StringConcat_01("Iam",props->name));它通过宏,隐藏了 2 个编译时确定的参数:第一个参数,代表宿主函数的索引号,即上面 WasmtsMeta 中,require 数组的第 0 个函数,即 alert。第二个参数,代表调用时的函数签名,即上面 WasmtsMeta 中,signature 数组第 0 个签名定义,即 "0*12",它由返回值类型 typeId,各个参数类型 typeId 组成。解析后即为 "void*string"。我们可参考下图进行详细说明:图 7. Bytets 中 WebAssembly 和 JS 交互在 WebAssembly 层,代码运行到 alert_01 时,实际是 __host_call ,被识别为宿主函数,则把参数信息传递给 Bytets JS 层运行时执行。Bytets 的 JS 层运行时,会根据第一个参数,由 WasmtsMeta.require 得到被执行的函数是 alert。根据第二个参数,得到WasmtsMeta.signature 被调用时的签名是 "0*12"(即“void*string”)。由此得知参数类型是字符串。由于第三个参数,是字符串的指针,所以 Bytets 的 JS 层运行时会到 WebAssembly 的线性内存读取该指针对应的字符串值。把上一步获取到的字符串值,传给 alert 函数进行调用。到这里,我们的 ByteReact 能够实现自动生成胶水代码了。对于宿主函数 alert 可以无顾虑地调用了。另外,Bytets 的胶水实现机制,可以实现更复杂参数类型的宿主函数调用。比如://你可以任意不同个参数,任意不同类型参数调用console.log等接口//下面代码编译成WebAssembly后,胶水代码都会帮你自动进行绑定console.log(1,23,"abc",{a:1,b:"bbb"});console.log("helloworld");console.warn("warn",{x:{a:1,b:"b"},y:2});再次回到我们的 ByteReact 示例代码,JSX 的解析没问题了,alert 的执行也没问题了。接下来,应用的入口(即 render 函数),如何执行的呢?render(,document.getElementById('root'));这部分代码,会被编译成大致如下代码:/****ByteReactHost_registerElementById_01实际跟alert_01一样,是调用宿主函数,是一个宏.*宏展开后如下:*__host_call(1,1,(number)(int32)“root”);**/_ByteReact_render(_ByteReact_createFunctionComponent(App,WSNative_createObject_001("Test")),ByteReactHost_registerElementById_01((number)(int32)"root"));由于 Bytets 仅对 JavaScript 宿主常用函数(如 alert、console)做了映射封装,而 BOM 对象(如 window、document)并没有进行映射。因此,document.getElementById('root') 并不能自动生成胶水。这就需依赖 ByteReact 的 JS 运行时层。ByteReact 的 JS 层,也利用了 Bytets 的胶水自动生成的机制,对 WebAssembly 层提供相关宿主接口。Bytets 在编译的时候,如果 import 一个函数,该函数所在文件的文件名以".wjs.ts"结尾,则被编译器认为它是纯 JS 函数,并不会把它编译成 WebAssembly ,而是自动生成胶水,并把它的相关信息放到 WasmtsMeta 中。(所以,如果用户在具体业务代码中,需要把一些代码保留为 JS 代码,不编译成 WebAssembly,则可以把它放到“.wjs.ts”结尾的文件中。然后从这个文件 import 即可。相应 WebAssembly 处调用,会通过胶水调用宿主相应函数。)整体结构如下:图 8. BytetReact 中 WebAssembly 和 JS 交互图中,ByteReactHost_registerElementById_01,是 ByteReact JS 层运行时提供的一个宿主函数,在编译的时候,它会被注册到上述的 WasmtsMeta.require 中。在 WebAssembly 层,运行到此函数时,定位到目标宿主函数为 Bytets JS 运行时层的 ByteReactHost_registerElementById_01 。在解析完所需参数后,调用此函数,得到返回值 hostId 。这个 hostId 即是跟真实 Dom 对象绑定的唯一 Id。综上,我们可以得到,我们的示例代码,被编译成 C++ 后的大致代码如下:voidwsfunc_0001(WSContext0001context){alert_01((number)(int32)__StringConcat_01("Iam",context->props->name));}HostComponentApp(WSObject0001props){WSContext0001context0001=WSCreateContext_0001(props);WSObject033Funcclosure35=AsWSObject033Func(WSNative_ClosureNew(224,wsfunc_0001,context0001));WSObject034Funccallback=useCallback(closure35,WSStringArray_create_01(props->name));return_ByteReact_createHostComponent('div',{//为了方便展示,此对象还是用json表示,并非真正C++代码children:[_ByteReact_createHostComponent('button',{onClick:callback,children:`Iam${props.name}`})]});}voidmain(){_ByteReact_render(_ByteReact_createFunctionComponent(App,WSNative_createObject_001("Test")),ByteReactHost_registerElementById_01((number)(int32)"root"));}上面的 C++ 代码,会被编译成 WebAssembly,运行在 WebAssembly 层。我们把代码执行流程大致捋一捋:main 函数作为入口被执行,进入后,首先执行 _ByteReact_createFunctionComponent。此函数会调用 App 函数。在 App 函数中,会调用 _ByteReact_createHostComponent 等函数,构建一个虚拟 Dom 树,同时,构建相应的 fiber 树。执行完 App 函数后,再跳回 main 函数,开始执行 ByteReactHost_registerElementById_01。这是一个宿主函数,是 ByteReact JS 层运行时提供的一个接口。目的是根据 ID 获取真实的 dom 对象,并注册一个 hostId,返回给 WebAssembly 层使用。执行完 ByteReactHost_registerElementById_01 后,回到 main 函数,开始执行 _ByteReact_render。此函数会把整个虚拟 dom 树,生成一个完整的 html 字符串,传到 ByteReact JS 层运行时,在 JS 层插入到上一步骤绑定的真实 dom 对象下。至此,渲染完毕。以上就是 ByteReact 的基本实现原理。6. 进一步优化完成前面的步骤,我们就有了一个能完全编译成 WebAssembly 的 ByteReact 框架。在实际与业务沟通的过程中,发现很难落地,有较高的门槛。其中,最大的问题是,它要求把整个应用都被编译成 WebAssembly,有如下缺点:违反了渐进式原则。编译成 WebAssembly 的代码,要求要用严格的 TypeScript 写,如果对整个项目都这样要求,未免过于苛刻。编译成的 WebAssembly ,实际上是二进制的字节码,包的体积会比 JavaScript 大很多。如果整个应用编译成一个 WebAssembly 文件,则会造成单个文件很大。大部分应用都会依赖很多第三方 JavaScript 库,这些代码不是 TypeScript 写的,无法编译成为 WebAssembly。由于以上的缺点,接入成本会变得很高,风险很大。后来, ByteReact 的整个架构进行了改造。采用如下图结构:图 9. 新的 ByteReact 架构具体说明如下:ByteReact 整体不会编译成 WebAssembly ,还是以 JavaScript 的形式运行,统一管理所有控件。提供 useWasmFunction , useWasmComponent 接口,让用户指定某些 function 或控件要被编译成 WebAssembly。提供 useWasmStatus 接口,获取相应的 WebAssembly 控件或 function 是否成功加载。被指定编译成 WebAssembly 的代码,将跟 ByteReact 运行时代码同时编译成 WebAssembly,挂载到总的 JavaScript 形式运行的 ByteReact 运行时下。被指定编译成 WebAssembly 的代码,系统同时保留其 JavaScript 版本和 WebAssembly 版本。在初次启动时,首先同步加载 JavaScript 版本,之后再异步加载其 WebAssembly 代码。最后相关控件,无缝切换到 WebAssembly 版本进行托管。简单示例代码如下:functionFibonacci(n:number):number{if(n==1)return1;if(n==2)return2;returnFibonacci(n-1)+Fibonacci(n-2);}functionDemoComponent(props:{name:string,n:number}){//如果 Fibonacci 的 WebAssembly 版本成功加载,则返回 WebAssembly 版本,否则返回 JS 版本。constWasmFibonacci=useWasmFunction(Fibonacci);letvalue=WasmFibonacci(props.n);return( Mynameis{props.name},thevalueofFibonacciwith{props.n}is:{value});}采用这种形式,有以下优势:WebAssembly 包被分割为组件级别,确保单个 WebAssembly 包不会无限扩大。启动时运行 JavaScript,而 WebAssembly 包延迟加载,不影响用户体验,只有加速效果不会有负面影响。保留了 JavaScript 版本代码,保证在不支持 WebAssembly 的客户端,能优雅降级。大部分代码是 JavaScript,不需要严格强类型编写,也可以随意引用第三方库。降低开发者心理负担。至于 WebAssembly 包异步方式延迟加载,会不会造成切换过程“闪一下”呢?事实上并不会,因为 ByteReact 底层依赖于 React 最新的 Hooks Api 理念,每个控件都是无副作用,可重复执行的。在 JavaScript 状态下渲染完成后,等待 WebAssembly 成功加载。WebAssembly 成功加载后,会把 JavaScript 层的相应状态都同步进 WebAssembly 层。此时交给 WebAssembly 层控件进行托管。整个过程是无缝、无差异感知衔接的。具体细节,限于篇幅,不做详细说明。另一方面,为了让已有的 React 项目,能够无缝迁移到 ByteReact,ByteReact 也对 React 的官方接口进行兼容。对于一个已有的 React 项目,只要修改一下构建工具的配置,无需修改任何业务代码,即可实现零成本无缝切换到 ByteReact。哪怕这个项目依赖到第三方大型 UI 库,如 antd-mobile,都可以无缝兼容。目前支持的构建工具,包括 Webpack、Vite。下面是 Webpack 的简单示例, 安装完 ByteReact 后,在 webpack.config.js 文件,添加如下 alias 选项:module.exports={resolve:{alias:{...require("@bytets/byte-react/plugin-webpack").alias()}},};7. 最终性能收益整个项目下来,ByteReact 的最终性能收益比 React 快多少呢?测试基准:GitHub 上比较权威的前端框架简单测试例子 js-framework-benchmark [1](目前 GitHub 有 5k 赞)计时区间:按钮被按下时开始计时,触发 useLayoutEffect 时,计时结束测试环境:Mac 电脑,Chrome 浏览器测试数据如下:从测试数据可以看到,在基本是纯 DOM 操作中(如:Clear ),性能优化不明显,因为 DOM 操作耗时部分是必须的耗时;在 DOM 耗时比较少的操作中(如 Swap Rows),性能甚至能提升将近 3 倍。这也是符合最初如下图预测的。图 10. ByteReact 相比其它框架的核心优势所以说,凡是业务代码越复杂的业务,编译成 WebAssembly,收益就越大。可观的情况下,有 1 到 3 倍的性能收益。8. 总结这是一次对 WebAssembly 技术在类 React 前端框架中的有趣探索。从最初的理论设想,到一步步实现,最后验证了最初的设想,达到预期的提速收益。这得益于 Bytets 编译器也是本人设计的,可以针对 ByteReact 的需求,进行定制化设计。ByteReact 的未来依旧还有不少要解决的问题,首先是 web 前端应用都是轻运算逻辑应用,对运行速度要求不高。这使得 ByteReact 的落地场景比较少,实际价值没有预想的那么大。其次是,WebAssembly 还是比较新的技术,其生态还有待进一步繁荣。尤其在 TypeScript 编译成 WebAssembly,尽管已知有很多大公司都在做这一尝试,但基本是各自为政,没有一个统一的标准。假如这个标准统一了,大部分开发者也接受并习惯按照这个标准写应用,那这个生态就真正繁荣了。9. 参考文献[1]. js-framework-benchmark: https://github.com/krausest/js-framework-benchmark点击上方关注 · 技术干货不迷路点击左下方“阅读原文”,或扫描上方二维码,进入专栏阅读《走进 WebAssembly 的世界》完整版。
回复

使用道具 举报

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

本版积分规则

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

GMT+8, 2025-1-12 08:39 , Processed in 0.554582 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

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