一个组件最核心的就是 render
函数,剩余的其他内容,如data、compouted、props 等都是为render函数提供数据来源服务的,render函数可以产出 Virtual DOM。
Virtual DOM 最终都要渲染成真实的DOM,这个过程就叫做patch。
什么是 Vnode
vue首先会将template进行编译,这其中包括parse、optimize、generate三个过程。 parse会使用正则等方式解析template模版中的指令、class、style等数据,形成AST。
<ul id='list' class='item'>
<li class='item1'>Item 1</li>
<li class='item3' style='font-size: 20px'>Item 2</li>
</ul>
var element = {
tag: 'ul', // 节点标签名
data: { // DOM的属性,用一个对象存储键值对
class: 'item',
id: 'list'
},
children: [ // 该节点的子节点
{tag: 'li', data: {class: 'item1'}, children: {tag: null, data: null, children: "Item 1"}},
{tag: 'li', data: {class: 'item3', style: 'font-size: 20px'}, children: {tag: null, data: null, children: "Item 2"}},
]
}
如上面代码所述,一个 template 模版可以用AST语法树进行描绘。我们使用 tag
属性来存储标签的名字,用 data
属性来存储该标签的附加信息,比如 style、class、事件等。children
用来描述子节点。
Vnode 种类
上面讲述的是一些普通HTML标签,比如div、span、p这种,但是在实际的代码开发过程中我们会抽离出很多组件
<div>
<MyComponent />
</div>
像这种组件仍然需要使用 VNode 来描述 。并给此类用来描述组件的 VNode 添加一个标识,以便在挂载的时候有办法区分一个 VNode 到底是普通的 html 标签还是组件。
const elementVNode = {
tag: 'div',
data: null,
children: {
tag: MyComponent,
data: null
}
}
因此我们可以使用 tag
来判断将要挂载的内容,通过不同的渲染函数去渲染对应的HTML结构。
// 函数式组件
function MyComponent(props) {}
// 有状态组件
class MyComponent {}
除组件之外,还有两种类型需要描述,即 Fragment 和 Portal。
在vue3中 template 已经不需要一个大盒子进行包裹所有的html内容了,也就是我们简称的根元素。
<template>
<td></td>
<td></td>
<td></td>
</template>
模板中不仅仅只有一个 td 标签,而是有多个 td 标签,即多个根元素,需要引入一个抽象元素,也就是我们要介绍的 Fragment。将 tag 标记为 Fragment,只需要把该VNode 的子节点渲染到页面。
再来看看 Portal,什么是 Portal 呢?就是把子节点渲染到给定的目标。
const portalVNode = {
tag: Portal,
data: {
target: '#app-root'
},
children: {
tag: 'div',
data: {
class: 'overlay'
}
}
}
无论在何处使用 组件,它都会把内容渲染到 id="app-root" 的元素下。
总的来说,我们可以把 VNode 分成五类,分别是:html/svg 元素、组件、纯文本、Fragment 以及 Portal:
优化: flags 作为 VNode 的标识
既然 VNode 有类别之分,我们就有必要使用一个唯一的标识,来标明某一个 VNode 属于哪一类。同时给 VNode 添加 flags 也是 Virtual DOM 算法的优化手段之一。
在vue2中区分vnode 类型步骤:
- 拿到vnode 尝试把它作为组件去处理,如果成功的创建了组建,那说明该vnode 就是组建的vnode
- 如果没有成功创建,则检查 vnode.tag 是否有定义,如果有定义当作普通标签处理
- 如果 vnode.tag 没有定义则检查是否是注视节点
- 如果不是注释节点,则把他当作文本对待。
以上这些判断都是在挂载(或patch)阶段进行的,换句话说,一个 VNode 到底描述的是什么是在挂载或 patch 的时候才知道的。这就带来了两个难题:无法从 AOT 的层面优化、开发者无法手动优化。
为了解决这个问题,我们的思路是在 VNode 创建的时候就把该 VNode 的类型通过 flags 标明,这样在挂载或 patch 阶段通过 flags 可以直接避免掉很多消耗性能的判断。这也是我们需要讲的渲染器
// mount 函数的作用是把一个 VNode 渲染成真实 DOM,根据不同类型的 VNode 需要采用不同的挂载方式
export function mount(vnode, container) {
const { flags } = vnode
if (flags & VNodeFlags.ELEMENT) {
// 挂载普通标签
mountElement(vnode, container)
} else if (flags & VNodeFlags.COMPONENT) {
// 挂载组件
mountComponent(vnode, container)
} else if (flags & VNodeFlags.TEXT) {
// 挂载纯文本
mountText(vnode, container)
} else if (flags & VNodeFlags.FRAGMENT) {
// 挂载 Fragment
mountFragment(vnode, container)
} else if (flags & VNodeFlags.PORTAL) {
// 挂载 Portal
mountPortal(vnode, container)
}
}
至此,我们已经对 VNode 完成了一定的设计,目前为止我们所设计的 VNode 对象如下:
export interface VNode {
// _isVNode 属性在上文中没有提到,它是一个始终为 true 的值,有了它,我们就可以判断一个对象是否是 VNode 对象
_isVNode: true
// el 属性在上文中也没有提到,当一个 VNode 被渲染为真实 DOM 之后,el 属性的值会引用该真实DOM
el: Element | null
flags: VNodeFlags
tag: string | FunctionalComponent | ComponentClass | null
data: VNodeData | null
children: VNodeChildren
childFlags: ChildrenFlags
}
h 函数创建 VNode
组件 的 flags 类型
上面讲到了如何使用vnode 去描述一个template结构,我们可以用函数去控制如何自动生成vnode,这也是vue的一个核心api,vue3 h函数
h函数返回一个”虚拟节点“,通常缩写为 VNode:一个普通对象,其中包含向 Vue 描述它应在页面上渲染哪种节点的信息,包括所有子节点的描述。它的目的是用于手动编写的渲染函数:
render() {
return h('h1', null, '')
}
function h() {
return {
_isVNode: true,
flags: VNodeFlags.ELEMENT_HTML,
tag: 'h1',
data: null,
children: null,
childFlags: ChildrenFlags.NO_CHILDREN,
el: null
}
}
h 函数返回代码中的一些 vnode 信息,需要接受三个参数 tag、data 和 children。其中只需要确定 flags 和 childFlags 类型即可,其他的都是默认或者通过参数传递。
通过 tag 来确定 flags
需要注意的一点是:在 vue2 中用一个对象(object)作为组件的描述,在vue3中,有状态的组件是一个继承基类的类,是 Vue2 的对象式组件,我们通过检查该对象的 functional 属性的真假来判断该组件是否是函数式组件。在 Vue3 中,因为有状态组件会继承基类,所以通过原型链判断其原型中是否有 render 函数的定义来确定该组件是否是有状态组件
// 兼容 Vue2 的对象式组件
if (tag !== null && typeof tag === 'object') {
flags = tag.functional
? VNodeFlags.COMPONENT_FUNCTIONAL // 函数式组件
: VNodeFlags.COMPONENT_STATEFUL_NORMAL // 有状态组件
} else if (typeof tag === 'function') {
// Vue3 的类组件
flags = tag.prototype && tag.prototype.render
? VNodeFlags.COMPONENT_STATEFUL_NORMAL // 有状态组件
: VNodeFlags.COMPONENT_FUNCTIONAL // 函数式组件
}
children 的 flags 类型
children 可以分为四种
- children 是一个数组 h('ul', null, [h('li'),h('li')])
- children 是一个 vnode 对象 h('div', null, h('span'))
- children 是一个普通的文本字符串 h('div', null, '我是文本')
- 没有children h('div')
children 是数组可以分为两种,一种是有key、另一种就是无key的情况,都会被标志为KEYED_VNODES, 没有key的会调用 normalizeVNodes 进行人工干预生成key
// 多个子节点,且子节点使用key
childFlags = ChildrenFlags.KEYED_VNODES
children = normalizeVNodes(children)
render 函数 渲染Vnode成真实 DOM
渲染器的工作主要分为两个阶段:mount
, path
。如果旧的vnode存在,则会使用新的vnode与旧的vnode进行对比,试图以最小的资源开销完成 DOM 更新,这个过程就叫做patch
,如果旧的 vnode 不存在,则直接将新的 vnode 挂载成全新的 DOM, 这给过程叫mount
。
render 函数接收两个参数,第一个参数是将要被渲染的 vnode 对象,第二个参数是一个用来承载内容的容器,通常也叫挂载点,
function render(vnode, container) {
const prevVNode = container.vnode
if (prevVNode == null) {
if (vnode) {
// 没有旧的 VNode,只有新的 VNode。使用 `mount` 函数挂载全新的 VNode
mount(vnode, container)
// 将新的 VNode 添加到 container.vnode 属性下,这样下一次渲染时旧的 VNode 就存在了
container.vnode = vnode
}
} else {
if (vnode) {
// 有旧的 VNode,也有新的 VNode。则调用 `patch` 函数打补丁
patch(prevVNode, vnode, container)
// 更新 container.vnode
container.vnode = vnode
} else {
// 有旧的 VNode 但是没有新的 VNode,这说明应该移除 DOM,在浏览器中可以使用 removeChild 函数。
container.removeChild(prevVNode.el)
container.vnode = null
}
}
}
旧的vnode | 新的vnode | 操作 | ❌ | ✅ | 调用 mount 函数 | ✅ | ❌ | 移除DOM | ✅ | ✅ | 调用 patch 函数 |
---|
渲染器的责任非常之大,是因为它不仅仅是一个把 VNode 渲染成真实 DOM 的工具,它还负责以下工作:
- 控制部分组件生命周期钩子的调用,组件的挂载,卸载调用时机。
- 多端渲染的桥梁。
自定义渲染器的本质就是把特定平台操作dom 的方法从核心算法中抽离,并提供可配置的方案
- 异步渲染有直接的关系
vue3 的异步渲染是基于调度器的实现,若要实现异步渲染,组件的挂载酒不能同步进行,dom 的变更就要在合适的机会,???
- 包含最核心的算法 Diff 算法
渲染普通标签元素
上面在讲 flags 的时候也说过,不同tag 会被h 函数打上 flags,通过 flags 的不同我们就可以区分出需要渲染的内容是什么类型。不同的vnode 采用不同的挂载函数
接下来我们将围绕这三个问题去完成普通标签元素渲染的过程
- VNode 被渲染为真实DOM之后,没有引用真实DOM元素
- 没有将 VNodeData 应用到真实DOM元素上
- 没有继续挂载子节点,即 children
问题1
function mountElement(vnode, container) {
const el = document.createElement(vnode.tag)
vnode.el = el
container.appendChild(el)
}
问题2 通过遍历 VNodeData, switch 取值作用到元素上
// 拿到 VNodeData
const data = vnode.data
if (data) {
// 如果 VNodeData 存在,则遍历之
for(let key in data) {
// key 可能是 class、style、on 等等
switch(key) {
case 'style':
// 如果 key 的值是 style,说明是内联样式,逐个将样式规则应用到 el
for(let k in data.style) {
el.style[k] = data.style[k]
}
break
}
}
}
问题2 递归挂载子节点
// 拿到 children 和 childFlags
const childFlags = vnode.childFlags
const children = vnode.children
// 检测如果没有子节点则无需递归挂载
if (childFlags !== ChildrenFlags.NO_CHILDREN) {
if (childFlags & ChildrenFlags.SINGLE_VNODE) {
// 如果是单个子节点则调用 mount 函数挂载
mount(children, el)
} else if (childFlags & ChildrenFlags.MULTIPLE_VNODES) {
// 如果是单多个子节点则遍历并调用 mount 函数挂载
for (let i = 0; i < children.length; i++) {
mount(children[i], el)
}
}
arrtibutes跟props的区别:浏览器在加载页面之后会对页面中的标签进行解析,并生成与之相符的 DOM 对象,每个标签中都可能包含一些属性,如果这些属性是标准属性,那么解析生成的DOM对象中也会包含与之对应的属性。如果是非标准属性,那么则会当作是props处理
关于其他的比如class、arrtibutes、props、事件的处理可以移步文档
渲染纯文本、Fragment 和 Portal
- 纯文本
纯文本最简单,只需要将元素添加到页面上即可
function mountText(vnode, container) {
const el = document.createTextNode(vnode.children)
vnode.el = el
container.appendChild(el)
}
- Fragment
对于Fragment 不需要渲染,只需要挂载children,如果有多个子节点的话遍历挂载调用mount即可,如果是空节点则创建一个空的文本节点 调用mountText 挂载。
Fragment 类型的 VNode 来说,当它被渲染为真实DOM之后,其 el 属性的引用
如果只有一个节点,那么 el 属性就指向该节点;如果有多个节点,则 el 属性值是第一个节点的引用;如果片段中没有节点,即空片段,则 el 属性引用的是占位的空文本节点元素
在 patch 阶段对 dom 元素进行移动时,应该确保其放到正确的位置,而不应该始终使用 appendChild 函数,有时需要 insertBefore 函数,这时候我们就需要拿到相应的节点应用,这时候 vnode.el 属性是必不可少的,即使 fragment 没有子节点我们依然需要一个占位的空文本节点作为位置的引用。
- Portal
Portal挂载跟Fragment 一样,只需要特别注意的是Portal的tag是挂载点。
protal 所描述的内容可以挂载到任何位置,但仍然需要一个占位元素,并且 protal 类型的vnode 的 el 属性应该指向该占位元素 这是因为 Portal 的另外一个特性:虽然 Portal 的内容可以被渲染到任意位置,但它的行为仍然像普通的DOM元素一样,如事件的捕获/冒泡机制仍然按照代码所编写的DOM结构实施。要实现这个功能就必须需要一个占位的DOM元素来承接事件。但目前来说,我们用一个空的文本节点占位即可
渲染组件
还是通过vnode.flags 来判断挂载的vnode 是否属于有状态组件还是函数组件。
挂载一个有状态组件,也就是class 类组件
class MyComponent {
render() {
return h(
'div',
{
style: {
background: 'green'
}
},
[
h('span', null, '我是组件的标题1......'),
h('span', null, '我是组件的标题2......')
]
)
}
}
// 组件挂载
function mountStatefulComponent(vnode, container) {
// 创建组件实例
const instance = new vnode.tag()
// 渲染VNode
instance.$vnode = instance.render()
// 挂载
mount(instance.$vnode, container)
// el 属性值 和 组件实例的 $el 属性都引用组件的根DOM元素
instance.$el = vnode.el = instance.$vnode.el
}
函数式组件直接返回vnode 的函数
function MyFunctionalComponent() {
// 返回要渲染的内容描述,即 VNode
return h(
'div',
{
style: {
background: 'green'
}
},
[
h('span', null, '我是组件的标题1......'),
h('span', null, '我是组件的标题2......')
]
)
}
如下是 函数式组件 mountFunctionalComponent 函数的实现:
function mountFunctionalComponent(vnode, container, isSVG) {
// 获取 VNode
const $vnode = vnode.tag()
// 挂载
mount($vnode, container)
// el 元素引用该组件的根元素
vnode.el = $vnode.el
}
patch 函数更新渲染的DOM
上一节讲没有旧的 vnode, 使用 mount 函数挂载全新的 vnode。那么有 vnode 应该以何种合适的方式更新 DOM,这也就是我们常说的 patch。
当使用 render 渲染一个全新的 vnode, 会调用 mount 函数挂载该vnode,同时让容器元素存储该vnode 对象的引用。这样在此调用渲染器渲染新的vnode 对象到相同的容器元素,由于旧的 vnode 已经存在,所以会调用 patch 函数以合适的方式进行更新
更新普通标签元素
例如 ul 标签下只能渲染 li 标签,所以拿 ul 标签和一个 div 标签进行比对是没有任何意义的,这种情况下我们不会对旧的标签元素打补丁,而是使用新的标签元素替换旧的标签元素。
如果新旧 vnode 标签相同,那么不同的只有 VNodeData 和 children。本质上还是对这两个值做对比。
更新 VNodeData 时的思路分为以下几步:
- 第1步:当新的 VNodeData 存在时,遍历新的 VNodeData。
- 第2步:根据新的 VNodeData 中的key,分贝尝试读取旧值和新值。即prevValue 和 nextValue
- 第3步:使用switch...case 语句匹配不同的数据进行不同的更新操作。
以样式(style)的更新为例,如上代码所展示的更新过程是:
- 遍历新的样式数据,将新的样式数据全部应用到元素上
- 遍历旧的样式数据,将那些不存在新的样式数据中的样式从元素上移除
子节点的更新,主要是在patchElement函数中递归的调用patchChildren。注意对于子节点的比较只能是 同层级
的比较。
// 调用 patchChildren 函数递归地更新子节点
patchChildren(
prevVNode.childFlags, // 旧的 VNode 子节点的类型
nextVNode.childFlags, // 新的 VNode 子节点的类型
prevVNode.children, // 旧的 VNode 子节点
nextVNode.children, // 新的 VNode 子节点
el // 当前标签元素,即这些子节点的父节点
)
因为子节点的状态总共可以分为三种,一种是没有子节点,一种是子节点只有一个,最后一种就是子节点多个的情况,子节点同级比较因此就会出现九种情况。
实际上在整个新旧 children 的比对中,只有当新旧子节点都是多个子节点时才有必要进行真正的核心 diff,从而尽可能的复用子节点。 后面有章节也会着重讲解diff如何尽可能的复用子节点。
更新纯文本、Fragment 和 Portal
- 纯文本
纯文本的更新可以通过 DOM 对象的 nodeValue 属性读取或设置文本节点(或注释节点)的内容
function patchText(prevVNode, nextVNode) {
// 拿到文本元素 el,同时让 nextVNode.el 指向该文本元素
const el = (nextVNode.el = prevVNode.el)
// 只有当新旧文本内容不一致时才有必要更新
if (nextVNode.children !== prevVNode.children) {
el.nodeValue = nextVNode.children
}
}
- Fragment
由于 Fragment 没有包裹元素,只有子节点,所以我们对 Fragment 的更新本质上就是更新两个片段的“子节点”。直接调用标签元素的patchChildren函数,只需要注意el的指向。
- 如果新的片段 children 是单个子节点,则意味着其 vnode.children 属性的值就是 VNode 对象 nextVNode.el = nextVNode.children.el
- 如果新的片段 children 是空文本节点。prevVNode.el 属性引用就是该空文本节点 nextVNode.el = prevVNode.el
- 如果新的片段 children 是多个子节点。 nextVNode.el = nextVNode.children[0].el
- Portal
portal 也是一样的,没有元素包裹只需要比较子节点,并且注意el指向就可以nextVNode.el = prevVNode.el。
如果新旧容器不同,才需要搬运。这块也就不扩展了,感兴趣的可以查看文档
更新组件
更新有状态组件
有状态组件更新可以分为两种,一种是 主动更新
和 被动更新
。
主动更新:就是组件自身的状态发生改变所导致的更新。例如data的变化等等情况
被动更新:就是组件外部因素导致的,例如props的改变
1. 主动更新
比如我们需要更新这样的组件该如何做呢?
class MyComponent {
// 自身状态 or 本地状态
localState = 'one'
// mounted 钩子
mounted() {
// 两秒钟之后修改本地状态的值,并重新调用 _update() 函数更新组件
setTimeout(() => {
this.localState = 'two'
this._update()
}, 2000)
}
render() {
return h('div', null, this.localState)
}
}
可以回忆一下组件的挂载步骤:
- 创建组件的实例
- 调用组件的 render 获得vnode
- 将 vnode 挂载到容器元素上
- el 属性值 和 组件实例的 $el 属性都引用组件的根DOM元素
我们将所有的操作都封装到一个_update函数里。
function mountStatefulComponent(vnode, container, isSVG) {
// 创建组件实例
const instance = new vnode.tag()
instance._update = function() {
// 1、渲染VNode
instance.$vnode = instance.render()
// 2、挂载
mount(instance.$vnode, container, isSVG)
// 4、el 属性值 和 组件实例的 $el 属性都引用组件的根DOM元素
instance.$el = vnode.el = instance.$vnode.el
// 5、调用 mounted 钩子
instance.mounted && instance.mounted()
}
instance._update()
}
当更新时只需要再次调用_update函数即,那么如何需要去判断一个组件是第一次渲染还是需要更新你,通过设置一个变量_mounted boolean类型用来标记即可。
function mountStatefulComponent(vnode, container, isSVG) {
// 创建组件实例
const instance = new vnode.tag()
instance._update = function() {
// 如果 instance._mounted 为真,说明组件已挂载,应该执行更新操作
if (instance._mounted) {
// 1、拿到旧的 VNode
const prevVNode = instance.$vnode
// 2、重渲染新的 VNode
const nextVNode = (instance.$vnode = instance.render())
// 3、patch 更新
patch(prevVNode, nextVNode, prevVNode.el.parentNode)
// 4、更新 vnode.el 和 $el
instance.$el = vnode.el = instance.$vnode.el
} else {
// 1、渲染VNode
instance.$vnode = instance.render()
// 2、挂载
mount(instance.$vnode, container, isSVG)
// 3、组件已挂载的标识
instance._mounted = true
// 4、el 属性值 和 组件实例的 $el 属性都引用组件的根DOM元素
instance.$el = vnode.el = instance.$vnode.el
// 5、调用 mounted 钩子
instance.mounted && instance.mounted()
}
}
instance._update()
}
组件的更新大致可以分为三步:
- 获取旧的vnode
- 重新调用render 函数产生新的 vnode
- 调用patch 函数对比新旧 vnode
2. 被动更新
我们可以在组件实例创建之后立即初始化组件的 props,
instance.$props = vnode.data
这样子组件中就可以通过 this.$props.text 访问从父组件传递进来的 props 数据
举个案例:
第一次渲染产出的 VNode 是:
const prevCompVNode = h(ChildComponent, {
text: 'one'
})
第二次渲染产出的 VNode 是:
const prevCompVNode = h(ChildComponent, {
text: 'two'
})
由于渲染出来的tag都是组件,所以在 patch 函数内部会调用 patchComponent 函数进行更新
function patchComponent(prevVNode, nextVNode, container) {
// 检查组件是否是有状态组件
if (nextVNode.flags & VNodeFlags.COMPONENT_STATEFUL_NORMAL) {
// 1、获取组件实例
const instance = (nextVNode.children = prevVNode.children)
// 2、更新 props
instance.$props = nextVNode.data
// 3、更新组件
instance._update()
}
}
有状态组件更新可以分为三步:
- 通过prevVNode.childredn 拿到组件实例
- 更新props,使用新的VNodeData重新设置组件实例的
$props
属性 - 由于组件的
$props
已经更新,所以在调用组件的 _update 方法,让组件重新渲染
function replaceVNode(prevVNode, nextVNode, container) {
container.removeChild(prevVNode.el)
// 如果将要被移除的 VNode 类型是组件,则需要调用该组件实例的 unmounted 钩子函数
if (prevVNode.flags & VNodeFlags.COMPONENT_STATEFUL_NORMAL) {
// 类型为有状态组件的 VNode,其 children 属性被用来存储组件实例对象
const instance = prevVNode.children
instance.unmounted && instance.unmounted()
}
mount(nextVNode, container)
}
特别强调shouldUpdateComponent
在vue2中没有shouldUpdateComponent这个生命周期。在某些情况下,组件不需要更新,但是组件依旧跑了一次update。因此我们使用patchFlag和props的简单对比等方式来决定是否update。这就是shouldUpdateComponent的作用。
更新函数式组件
无论是有状态组件和函数式组件都是组件通过执行 _update
产出新旧vnode做对比,从而完成更新。
- 函数式组件接受props只能在mount阶段传递过去
function mountFunctionalComponent(vnode, container, isSVG) {
// 获取 props
const props = vnode.data
// 获取 VNode 执行函数并将props传递到函数中
const $vnode = (vnode.children = vnode.tag(props))
// 挂载
mount($vnode, container, isSVG)
// el 元素引用该组件的根元素
vnode.el = $vnode.el
}
- 函数式组件通过定义一个函数在VNode中定义一个handle函数将整个挂载的过程都实现,下次更新的时候只需要执行vnode的handle 函数即可。
vnode.handle = {
prev: null,
next: vnode,
container,
update() {/*...*/}
}
参数说明:
- prev: 存储旧的函数式组件vnode,在初次挂载时,没有旧的vnode
- next: 存储新的函数式组件vnode, 在初次挂载时,被赋值为当前正在挂载的函数式组件
- container: 存储的挂载容器
具体的实现过程也基本和有状态组件类似,具体的可参考文档
下一节
下一节主要介绍diff算法如果尽可能的复用dom元素
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!