渲染类组件

示例

// src/index.js
import React, { render, Component } from './react'const root = document.getElementById('root')// const jsx = (
//   <div>
//     <p>Hello React</p>
//     <p>Hi Fiber</p>
//   </div>
// )
// render(jsx, root)class Greating extends Component {constructor(props) {super(props)}render() {return <div>Hello Class Component</div>}
}
render(<Greating />, root)

添加继承类 Component

// src\react\Component\index.js
export class Component {constructor(props) {this.props = props}
}
// src/react/index.js
import createElement from './CreateElement'
export { render } from './Reconciliation'
export { Component } from './Component'
export default {createElement
}

扩展 getTag

扩展getTag(),支持组件类型:

// src\react\Misc\GetTag\index.js
import { Component } from "../../Component"const getTag = vdom => {if (typeof vdom.type === 'string') {// 普通节点return 'host_component'} else if (Object.getPrototypeOf(vdom.type) === Component) {// 类组件return 'class_component'} else {// 函数组件return 'function_component'}
}export default getTag

扩展 createStateNode

扩展createStateNode(),支持组件:

  • 类组件:返回组件实例对象
  • 函数组件:返回定义组件的方法
// src\react\Misc\CreateStateNode\index.js
import { createDOMElement } from '../../DOM'
import { createReactInstance } from '../CreateReactInstance'const createStateNode = fiber => {if (fiber.tag === 'host_component') {// 普通节点返回真实 DOM 对象return createDOMElement(fiber)} else {// 组件节点返回组件实例对象return createReactInstance(fiber)}
}export default createStateNode
// src\react\Misc\CreateReactInstance\index.js
/*** 获取组件的 StateNode* @param {*} fiber*/
export const createReactInstance = fiber => {let instance = nullif (fiber.tag === 'class_component') {// 类组件instance = new fiber.type(fiber.props)} else {// 函数组件instance = fiber.type}return instance
}

获取组件的 children

React 在运行之前 Babel 会将 JSX 转换成 React.createElement() 的调用。

如果 JSX 是普通元素,则会将子元素传递给 React.createElement()

const jsx = (<div><p>Hello React</p><p>Hi Fiber</p></div>
)

转换为:

const jsx = /*#__PURE__*/ React.createElement("div",null,/*#__PURE__*/ React.createElement("p", null, "Hello React"),/*#__PURE__*/ React.createElement("p", null, "Hi Fiber")
);

但如果是组件:

class ClassComponent extends React.Component {render() {return <div>Hi Class</div>}
}
const jsx_class = <ClassComponent />function FunctionComponent() {return <div>Hi Fcuntion</div>
}
const jsx_function = <FunctionComponent />

则直接将组件传递进去,组件的子级(即组件返回的 JSX 内容)需要通过调用组件的方法获取:

  • 类组件调用render()方法
  • 函数组件调用自身
class ClassComponent extends React.Component {render() {return /*#__PURE__*/ React.createElement("div", null, "Hi Class");}
}const jsx_class = /*#__PURE__*/ React.createElement(ClassComponent, null);function FunctionComponent() {return /*#__PURE__*/ React.createElement("div", null, "Hi Fcuntion");
}const jsx_function = /*#__PURE__*/ React.createElement(FunctionComponent, null);

executeTask() 中向构建子级 Fiber 对象的方法 reconcileChildren 传递参数的时候,之前仅处理了普通节点。

const reconcileChildren = (fiber, children) => {console.log(children);/*...*/
}
const executeTask = fiber => {// 构建子级 fiber 对象reconcileChildren(fiber, fiber.props.children)/*...*/
}

所以当前传递的组件的 fiber.props.children 为空 []

需要判断,当为组件节点的时候调用方法获取 children

const executeTask = fiber => {// 构建子级 fiber 对象if (fiber.tag === 'class_component') {reconcileChildren(fiber, fiber.stateNode.render())} else {reconcileChildren(fiber, fiber.props.children)}/*...*/
}

组件节点本身也是一个节点。

构建组件的 Fiber 节点,组件的子级是组件返回的 JSX 内容,而不是 JSX 内容的子级。

第二阶段-追加节点

现在类组件的 Fiber 对象渲染完成,进入第二阶段。

我们对组件和组件返回的 JSX 的节点都构建了 Fiber 对象,组件节点本身也是一个节点,但是组件节点本身不能作为真实的 DOM 元素去操作:

  • 被追加到页面中
  • 追加真实 DOM 元素

所以要递归查找组件节点的普通节点父级(组件可能被包含在另一个组件中,所以要向上递归查找),这样才能去操作 DOM 元素的追加。

并且在追加时判断,仅当节点是普通节点类型时,执行追加操作。

const commitAllWork = fiber => {fiber.effects.forEach(item => {if (item.effectTag === 'placement') {// 当前要追加的子节点的父级let parentFiber = item.parent/*** 找到普通节点父级 排除组件父级* 因为组件父级是不能直接追加真实 DOM 节点的*/while (parentFiber.tag === 'class_component') {parentFiber = parentFiber.parent}// 如果子节点时普通节点 将子节点追加到父级中if (item.tag === 'host_component') {parentFiber.stateNode.appendChild(item.stateNode)}}})
}

现在类组件就渲染完成,可以访问页面查看结果。

总结

  1. 设置类组件 Fiber 对象的 tag 属性为 class_component
  2. 设置类组件 Fiber 对象的 stateNode 属性为组件实例对象
  3. 通过调用类组件实例对象的 render() 方法获取组件的子级:组件返回的 JSX
  4. 追加组件内容:
    • 类组件节点不能作为真实 DOM 节点去追加内容和被追加
    • 需要向上循环递归查找它所属的普通节点类型的父级节点
    • 在追加节点时判断,只有普通节点可以被追加到页面

渲染函数组件

示例:

// src/index.js
import React, { render, Component } from './react'const root = document.getElementById('root')function FnComponent(props) {return <div>{props.title}</div>
}render(<FnComponent title="Function Component" />, root)

函数组件和类组件几乎一样,区别:

  • tag的不同

    • 类组件:class_component
    • 函数组件:function_component
  • stateNode 不同
    • 类组件:组件实例
    • 函数组件:组件本身(函数方法)
  • 获取子级的方式不同:
    • 类组件通过调用实例对象的 render()方法获取
    • 函数组件通过调用组件本身的方法获取

之前获取 tagstateNode的修改已经支持了函数组件。

获取函数组件的子级:

const executeTask = fiber => {// 构建子级 fiber 对象if (fiber.tag === 'class_component') {reconcileChildren(fiber, fiber.stateNode.render())} else if (fiber.tag === 'function_component') {reconcileChildren(fiber, fiber.stateNode(fiber.props))} else {reconcileChildren(fiber, fiber.props.children)}/*...*/
}

查找普通节点父级增加函数组件的判断:

const commitAllWork = fiber => {fiber.effects.forEach(item => {if (item.effectTag === 'placement') {// 当前要追加的子节点的父级let parentFiber = item.parent/*** 找到普通节点父级 排除组件父级* 因为组件父级是不能直接追加真实 DOM 节点的*/while (parentFiber.tag === 'class_component' || parentFiber.tag === 'function_component') {parentFiber = parentFiber.parent}// 如果子节点时普通节点 将子节点追加到父级中if (item.tag === 'host_component') {parentFiber.stateNode.appendChild(item.stateNode)}}})
}

函数组件渲染完成。

实现节点更新

当前仅处理普通节点的更新

实现思路

  • 当 DOM 初始化渲染完成之后,要备份旧的 Fiber 节点对象。
  • 当再次调用 render() 方法更新 DOM 的时候,又再次创建 FIber 节点对象
  • 当再次创建 Fiber 节点对象的时候要检查是否存在旧的 Fiber 节点对象
    • 如果存在,则表示当前执行的是更新操作

      • 此时就要创建执行更新操作的 Fiber 节点对象
    • 否则就是初始化渲染

示例

// src/index.js
import React, { render, Component } from './react'const root = document.getElementById('root')const jsx = (<div><p>Hello React</p><p>Hi Fiber</p></div>
)render(jsx, root)setTimeout(() => {const jsx = (<div><p>你好 React</p><p>Hi Fiber</p></div>)render(jsx, root)
}, 2000)

备份旧的 Fiber 节点对象

初始化渲染完成就是指 DOM 操作完成之后,也就是 commitAllWork() 中的内容执行完成之后。

在该方法中备份旧的 Fiber 节点对象,只需将根节点对应的 Fiber 对象存储到根节点对应的真实 DOM 对象上即可。

// src/react/Reconciliation/index.js
import { createTaskQueue, arrified, createStateNode, getTag } from '../Misc'
/*...*/// 存储根节点所对应的 Fiber 对象
let pendingCommit = nullconst commitAllWork = fiber => {// 循环 effects 数组 构建 DOM 节点树fiber.effects.forEach(item => {/*...*/})// 备份旧的 Fiber 节点对象fiber.stateNode.__rootFiberContainer = fiber
}/*...*/

根节点的 Fiber 对象中存储备份

Fiber 对象中的 alternate 属性存储旧 Fiber 对象的备份,用于新旧对比。

首先修改构建根节点 Fiber 对象的方法getFirstTask()

const getFirstTask = () => {// 从任务队列中获取任务const task = taskQueue.pop()// 返回最外层节点的 Fiber 对象return {props: task.props,stateNode: task.dom,tag: 'host_root',effects: [], // 暂不指定child: null, // 在构建子节点的时候指定其与父节点的关系alternate: task.dom.__rootFiberContainer // 旧的 Fiber 节点对象}
}

子节点的 Fiber 对象中存储备份

然后找到构建子节点 Fiber 对象的方法reconcileChildren()

  • 该方法中会循环构建节点的子节点
  • 在循环之前判断节点是否备份了旧 Fiber 对象
  • 如果有,则获取备份的Fiber对象中的子级(child:存储的是该节点下第一个子节点)
  • 然后进入构建子节点的循环中
  • 构建Fiber对象的时候,判断是否有备份,如果有则将备份存储到 alternate 属性
  • 然后判断该备份的 Fiber 对象中是否有兄弟节点(sibling
  • 如果有,则获取这个兄弟节点,它就是下次循环构建的子节点
const reconcileChildren = (fiber, children) => {// children 可能是对象,也可能是数组// 将 children 转换成数组const arrifiedChildren = arrified(children)// 循环 children 使用的索引let index = 0// children 数组中元素的个数let numberOfElements = arrifiedChildren.length// 循环过程中的循环项 就是子节点的 virtualDOM 对象let element = null// 子级 fiber 对象let newFiber = null// 上一个兄弟 fiber 对象let prevFiber = null// 循环过程中节点对应的备份 fiber 对象let alternate = nullif (fiber.alternate && fiber.alternate.child) {alternate = fiber.alternate.child}while (index < numberOfElements) {// 子级 virtualDOM 对象element = arrifiedChildren[index]// 子级 fiber 对象newFiber = {type: element.type,props: element.props,tag: getTag(element),effects: [], // 暂不指定effectTag: 'placement',parent: fiber,alternate}// 为 fiber 对象添加 DOM 对象或类组件实例对象或函数组件本身newFiber.stateNode = createStateNode(newFiber)// 指明父子关系、兄弟关系if (index === 0) {// 父节点的子节点只能是第一个子节点fiber.child = newFiber} else {// 其它的节点作为上一个节点的兄弟节点prevFiber.sibling = newFiber}if (alternate && alternate.sibling) {// 获取下一个节点的备份alternate = alternate.sibling} else {alternate = null}prevFiber = newFiberindex++}
}

根据操作构建不同的 Fiber 对象

在构建子节点的时候,还要判断当前要执行什么操作,从而构建不同操作所对应的 Fiber 对象:

  • 初始渲染
  • 更新操作
const reconcileChildren = (fiber, children) => {// children 可能是对象,也可能是数组// 将 children 转换成数组const arrifiedChildren = arrified(children)// 循环 children 使用的索引let index = 0// children 数组中元素的个数let numberOfElements = arrifiedChildren.length// 循环过程中的循环项 就是子节点的 virtualDOM 对象let element = null// 子级 fiber 对象let newFiber = null// 上一个兄弟 fiber 对象let prevFiber = null// 循环过程中节点对应的备份 fiber 对象let alternate = nullif (fiber.alternate && fiber.alternate.child) {alternate = fiber.alternate.child}while (index < numberOfElements) {// 子级 virtualDOM 对象element = arrifiedChildren[index]if (element && alternate) {/* 更新操作 */// 子级 fiber 对象newFiber = {type: element.type,props: element.props,tag: getTag(element),effects: [], // 暂不指定effectTag: 'update',parent: fiber,alternate}// 判断节点类型if (element.type === alternate.type) {/* 类型相同 */// 只需将之前的 stateNode 赋值给新的 fiber 对象即可newFiber.stateNode = alternate.stateNode} else {/* 类型不同 */// 为 fiber 对象添加 DOM 对象或类组件实例对象或函数组件本身newFiber.stateNode = createStateNode(newFiber)}} else if (element && !alternate) {/* 初始渲染操作 */// 子级 fiber 对象newFiber = {type: element.type,props: element.props,tag: getTag(element),effects: [], // 暂不指定effectTag: 'placement',parent: fiber}// 为 fiber 对象添加 DOM 对象或类组件实例对象或函数组件本身newFiber.stateNode = createStateNode(newFiber)}// 指明父子关系、兄弟关系if (index === 0) {// 父节点的子节点只能是第一个子节点fiber.child = newFiber} else {// 其它的节点作为上一个节点的兄弟节点prevFiber.sibling = newFiber}if (alternate && alternate.sibling) {// 获取下一个节点的备份alternate = alternate.sibling} else {alternate = null}prevFiber = newFiberindex++}
}

执行 DOM 操作

执行 DOM 操作是在 commitAllWork() 方法中。

  • 通过 Fiber 对象的effectTag属性判断执行的操作

    • update 更新节点
    • placement 追加节点
  • 如果是更新节点,继续判断节点类型是否相同
    • 节点类型不同,直接用新节点替换旧节点(调用父节点 DOM 的replaceChild()
    • 节点类型相同,执行更新操作(调用 updateNodeElement()
      • updateNodeElement() 方法接收的 VirtualDOM 就是 Fiber 对象,主要使用对象的 props 属性
// src/react/Reconciliation/index.js
import { updateNodeElement } from '../DOM'
import { createTaskQueue, arrified, createStateNode, getTag } from '../Misc'/*...*/const commitAllWork = fiber => {// 循环 effects 数组 构建 DOM 节点树fiber.effects.forEach(item => {if (item.effectTag === 'update') {/* 更新节点 */if (item.type === item.alternate.type) {/* 节点类型相同 */updateNodeElement(item.stateNode, item, item.alternate)} else {/* 节点类型不同 */item.parent.stateNode.replaceChild(item.stateNode, item.alternate.stateNode)}} else if (item.effectTag === 'placement') {/* 追加节点 *//*...*/}})// 备份旧的 Fiber 节点对象fiber.stateNode.__rootFiberContainer = fiber
}

扩展更新节点的方法 - 更新文本节点

当前更新节点的方法 updateNodeElement 是参考之前的《模拟 React》 文章复制来的。

该文中更新文本节点是调用的另一个方法,所以 updateNodeElement()中没有处理文本节点。

现在扩展这个方法,使其即能处理元素节点,也能处理文本节点:

// src\react\DOM\updateNodeElement.js
/*** @param {*} newElement 要更新的 DOM 元素对象* @param {*} virtualDOM 新的 Virtual DOM 对象* @param {*} oldVirtualDOM 旧的 Virtual DOM 对象*/
export default function updateNodeElement(newElement, virtualDOM = {}, oldVirtualDOM = {}) {// 获取节点对应的属性对象const newProps = virtualDOM.propsconst oldProps = oldVirtualDOM.props || {}// 文本节点更新操作if (virtualDOM.type === 'text') {if (newProps.textContent !== oldProps.textContent) {virtualDOM.parent.stateNode.textContent = newProps.textContent// 也可以使用替换节点的方式,但要判断父节点类型发生变化的情况// if (virtualDOM.parent.type != oldVirtualDOM.parent.type) {//   virtualDOM.parent.stateNode.appendChild(document.createTextNode(newProps.textContent))// } else {//   virtualDOM.parent.stateNode.replaceChild(//     document.createTextNode(newProps.textContent),//     oldVirtualDOM.stateNode//   )// }}return}// 属性被修改或添加属性的情况Object.keys(newProps).forEach(propName => {/*...*/})// 判断属性被删除的情况Object.keys(oldProps).forEach(propName => {/*...*/})
}

总结

  1. 在构建 Fiber 对象的时候要备份旧的 Fiber 对象

    1. 在初始渲染结束后(commitAllWork())将根节点的 Fiber 对象存储在真实 DOM 上(__rootFiberContainer
    2. 在构建根节点 Fiber 时(getFirstTask())将旧的根节点 Fiber 对象备份到 alternate 属性
    3. 在构建子节点时(reconcileChildren())备份旧的子节点 Fiber,还要根据操作构建不同操作类型的 Fiber 节点对象
      1. 首先判断父级是否有 alternate
      2. 如果有则获取 alternate 的子级(child),它是循环的第一个子节点的备份
      3. 循环子级节点,判断节点是否有对应的备份
        1. 如果有则为更新节点操作

          1. 将备份存储到alternate
          2. 判断节点类型是否相同
            1. 如果不同则需要重新获取 stateNode
            2. 如果相同则直接取 alternatestateNode
        2. 如果没有,则不需要对alternate赋值
      4. 接着判断alternate是否有兄弟节点
        1. 如果有则将兄弟节点作为下一轮循环的子节点的备份
  2. 构建完 Fiber 后操作 DOM 对象,commitAllWork()中循环根节点的 effects,也就是所有的 Fiber 对象,判断它的操作类型(effectTag):
    1. update 更新节点操作

      1. 判断节点类型

        1. 相同节点,执行更新节点操作

          1. 文本节点更新文本内容
          2. 其它节点更新它们的属性
        2. 不同节点,直接用新节点替换就节点
    2. placement 追加节点操作

实现节点删除

当前仅处理普通节点的删除

示例

// src/index.js
import React, { render, Component } from './react'const root = document.getElementById('root')const jsx = (<div><p>Hello React</p><p>Hi Fiber</p></div>
)render(jsx, root)setTimeout(() => {const jsx = (<div>{/* <h1>你好 React</h1> */}<p>Hi Fiber</p></div>)render(jsx, root)
}, 2000)

构建删除操作的 Fiber 节点对象

reconcileChildren() 中通过判断循环的判断当前如果是删除操作,就构建删除操作的 Fiber 节点对象。

  • 根据当前循环的子节点对应的 alternate是否存在, 判断节点是否被删除
  • 当子节点被清空的时候,子节点的数量为0,无法进入循环,所以要为 while 循环增加一个判断条件,判断是否有子级的备份
  • 并在进入循环后,判断当前子节点是否存在,以判断节点是否被删除
  • 当为删除节点操作时,将当前节点的备份 Fiber 中的 effectTag 设置为 delete 添加到 effects 中,在最终执行 DOM 操作的时候会处理
  • 在为上一个子节点设置兄弟节点的时候要判断当前节点是否存在,如果不存在则不设置兄弟节点
const reconcileChildren = (fiber, children) => {/*...*/while (index < numberOfElements || alternate) {// 子级 virtualDOM 对象element = arrifiedChildren[index]if (!element && alternate) {/* 删除操作 */alternate.effectTag = 'delete'fiber.effects.push(alternate)} else if (element && alternate) {/* 更新操作 *//*...*/} else if (element && !alternate) {/* 初始渲染操作 *//*...*/}// 指明父子关系、兄弟关系if (index === 0) {// 父节点的子节点只能是第一个子节点fiber.child = newFiber} else if (element) {// 其它的节点作为上一个节点的兄弟节点prevFiber.sibling = newFiber}/*...*/}
}

执行 DOM 删除操作

commitAllWork() 中判断,如果是删除操作,直接调用父节点的 removeChild()删除当前节点即可:

const commitAllWork = fiber => {// 循环 effects 数组 构建 DOM 节点树fiber.effects.forEach(item => {if (item.effectTag === 'delete') {/* 删除节点 */item.parent.stateNode.removeChild(item.stateNode)} else if (item.effectTag === 'update') {/* 更新节点 *//*...*/} else if (item.effectTag === 'placement') {/* 追加节点 *//*...*/}})/*...*/
}

React Fiber 04 - 渲染组件、节点更新、节点删除相关推荐

  1. React for循环渲染组件

    通常你需要在一个组件中渲染列表.或者循环遍历渲染相同的多个组件,下面看看怎么实现: render() {const options = this.state.data.map(d => < ...

  2. 这可能是最通俗的 React Fiber 打开方式

    作者:荒山 https://juejin.im/post/5dadc6045188255a270a0f85 温馨提示:由于 wx 外链限制,文中外链请点击阅读原文查看. 写一篇关于 React Fib ...

  3. react fiber架构学习

    同步更新过程的局限 在v16版本以前,react的更新过程是通过递归从根组件树开始同步进行的,更新过程无法被打断,当组件树很大的时候就会出现卡顿的问题 react中的虚拟dom import Reac ...

  4. React源码解读之React Fiber

    开始之前,先讲一下该文章能帮你解决哪些问题? 开始之前,先讲一下该文章能帮你解决哪些问题? facebook为什么要使用重构React React Fiber是什么 React Fiber的核心算法 ...

  5. 阿里三面:灵魂拷问——有react fiber,为什么不需要vue fiber?

    提到react fiber,大部分人都知道这是一个react新特性,看过一些网上的文章,大概能说出"纤程""一种新的数据结构""更新时调度机制&quo ...

  6. vue 渲染函数处理slot_面试官:Vue 和 React 对于组件的更新粒度有什么区别?

    前言 我们都知道 Vue 对于响应式属性的更新,只会精确更新依赖收集的当前组件,而不会递归的去更新子组件,这也是它性能强大的原因之一. 例子 举例来说 这样的一个组件: <div> {{ ...

  7. react循环setstate_react -- 关于兄弟组件触发更新的问题

    大家在用react的时候,应该都知道react的state更新会由父到子逐级更新的事情,那么就会有这样一个问题,当要渲染的页面元素较多,极端情况下,特别是大数据展示的时候,由顶层一点点逐级渲染,性能消 ...

  8. markdownpad2 html渲染组件出错_「万字长文」一文吃透React SSR服务端同构渲染

    写在前面 前段时间一直在研究 react ssr技术,然后写了一个完整的 ssr开发骨架.今天写文,主要是把我的研究成果的精华内容整理落地,另外通过再次梳理希望发现更多优化的地方,也希望可以让更多的人 ...

  9. 记一次 React 组件无法更新状态值的问题分析与解决

    文章出自个人博客 https://knightyun.github.io/2020/09/10/js-react-state-update,转载请申明 问题 React 组件中通过直接声明的元素变量( ...

  10. 从一个表格render方法问题看React函数组件的更新

    从一个表格render方法问题看React函数组件的更新 最近在开发中碰到了一个现象觉得很有典型能作为例子所以给大家分享一下,从这个现象我们能很清楚的看到函数组件的更新的特点,以及我们应该如何去理解和 ...

最新文章

  1. 122. Best Time to Buy and Sell Stock II
  2. com.sun.mail.smtp.SMTPSendFailedException: 550 Invalid User
  3. 百练4148:生理周期
  4. Stream流思想和常用方法
  5. 向MFC应用程序添加控制台窗口
  6. OSI 认证的开源 License 有哪些?
  7. python 学习笔记 while语句(11)
  8. 第 7 章 Neutron - 078 - 实践 Neutron 前的两个准备工作
  9. 奈奎斯特曲线怎么确定w的值matlab,用MATLAB绘制Nyquist图.ppt
  10. win7管理员取得所有权
  11. TS在vue中的应用
  12. 开源集市@中关村|气氛热烈,完美收官!
  13. 看看清华人是如何学习和生活的
  14. 创造与魔法241服务器系统什么时候修好,《创造与魔法》萌新小课堂——如何选择服务器...
  15. git推送代码详细教程
  16. Hive On Spark
  17. 浅谈单片机、ARM和DSP的异同——非常透彻
  18. 通过2-3-4树理解红黑树
  19. PCB应力应变测试分析结合IPC-9702和IPC-9704A标准
  20. 计算机usb端口没反应,如何解决win10系统电脑usb接口没反应的问题

热门文章

  1. 3. Git与TortoiseGit基本操作
  2. Weiss-(DSAA - in C,1.3)字谜游戏
  3. 由iconfont引起的svg、ttf、woff、woff2图标的研究及转换(svgs2fonts)
  4. 全国哀悼日 网站变灰代码集锦
  5. CMOS搭建反相器、与非门和或非门以及OD和三态门
  6. office文档转成pdf的两种方案
  7. Windows系统设置局域网共享(无密码+有密码)
  8. matlab中列主元三角分解法的函数,[数值算法]列主元三角分解法
  9. 办公技巧分享:如何把PDF转换成Word的5种方法
  10. UML结构建模图———复合结构图