有一段时间没有更新技术博文了,因为这段时间埋下头来看Vue源码了。本文我们一起通过学习双向绑定原理来分析Vue源码。预计接下来会围绕Vue源码来整理一些文章,如下。

这些文章统一放在我的git仓库:https://github.com/yzsunlei/javascript-series-code-analyzing。觉得有用记得star收藏。

简单应用

我们先来看一个简单的应用示例:

  1. <div id="app">
  2. <input id="input" type="text" v-model="text">
  3. <div id="text">输入的值为:{{text}}</div>
  4. </div>
  5. <script>
  6. var vm = new Vue({
  7. el: '#app',
  8. data: {
  9. text: 'hello world'
  10. }
  11. })
  12. </script>

上面的示例具有的功能就是初始时,'hello world'字符串会显示在input输入框中和div文本中,当手动输入值后,div文本的值也相应的改变。

我们来简单理一下实现思路:

  • 1、input输入框以及div文本和data中的数据进行绑定
  • 2、input输入框内容变化时,data中的对应数据同步变化,即 view => model
  • 3、data中数据变化时,对应的div文本内容同步变化,即 model => view

原理介绍

Vue.js是通过数据劫持以及结合发布者-订阅者来实现双向绑定的,数据劫持是利用ES5的Object.defineProperty(obj, key, val)来劫持各个属性的的setter以及getter,在数据变动时发布消息给订阅者,从而触发相应的回调来更新视图。

双向数据绑定,简单点来说分为三个部分:

  • 1、Observer:观察者,这里的主要工作是递归地监听对象上的所有属性,在属性值改变的时候,触发相应的watcher。
  • 2、Watcher:订阅者,当监听的数据值修改时,执行响应的回调函数(Vue里面的更新模板内容)。
  • 3、Dep:订阅管理器,连接Observer和Watcher的桥梁,每一个Observer对应一个Dep,它内部维护一个数组,保存与该Observer相关的Watcher。

DEMO实现双向绑定

下面我们来一步步的实现双向数据绑定。

第一部分是Observer:

  1. function Observer(obj, key, value) {
  2. var dep = new Dep();
  3. if (Object.prototype.toString.call(value) == '[object Object]') {
  4. Object.keys(value).forEach(function(key) {
  5. new Observer(value, key, value[key])
  6. })
  7. };
  8. Object.defineProperty(obj, key, {
  9. enumerable: true,
  10. configurable: true,
  11. get: function() {
  12. if (Dep.target) {
  13. dep.addSub(Dep.target);
  14. };
  15. return value;
  16. },
  17. set: function(newVal) {
  18. value = newVal;
  19. dep.notify();
  20. }
  21. })
  22. }

递归的为对象obj的每个属性添加getter和setter。在getter中,我们把watcher添加到dep中。在setter中,触发watcher执行回调。

第二部分是Watcher:

  1. function Watcher(fn) {
  2. this.update = function() {
  3. Dep.target = this;
  4. fn();
  5. Dep.target = null;
  6. }
  7. this.update();
  8. }

fn是数据变化后要执行的回调函数,一般是获取数据渲染模板。默认执行一遍update方法是为了在渲染模板过程中,调用数据对象的getter时建立两者之间的关系。因为同一时刻只有一个watcher处于激活状态,把当前watcher绑定在Dep.target(方便在Observer内获取)。回调结束后,销毁Dep.target。

第三部分是Dep:

  1. function Dep() {
  2. this.subs = [];
  3. this.addSub = function (watcher) {
  4. this.subs.push(watcher);
  5. }
  6. this.notify = function() {
  7. this.subs.forEach(function(watcher) {
  8. watcher.update();
  9. });
  10. }
  11. }

内部一个存放watcher的数组subs。addSub用于向数组中添加watcher(getter时)。notify用于触发watcher的更新(setter时)。

以上我们就完成了简易的双向绑定的功能,我们用一下看是不是能达到上面简单应用同样的效果。

  1. <div id="app">
  2. <input id="input" type="text" v-model="text">
  3. <div id="text">输入的值为:{{text}}</div>
  4. </div>
  5. <script type="text/javascript">
  6. var obj = {
  7. text: 'hello world'
  8. }
  9. Object.keys(obj).forEach(function(key){
  10. new Observer(obj, key, obj[key])
  11. });
  12. new Watcher(function(){
  13. document.querySelector("#text").innerHTML = "输入的值为:" + obj.text;
  14. })
  15. document.querySelector("#input").addEventListener('input', function(e) {
  16. obj.text = e.target.value;
  17. })
  18. </script>

当然上面这是最简单的双向绑定功能,Vue中还实现了对数组、对象的双向绑定,下面我们来看看Vue中的实现。

Vue中的双向绑定

看Vue的实现源码前,我们先来看下下面这张图,经典的Vue双向绑定原理示意图(图片来自于网络):

简单解析如下:

  • 1、实现一个数据监听器Obverser,对data中的数据进行监听,若有变化,通知相应的订阅者。
  • 2、实现一个指令解析器Compile,对于每个元素上的指令进行解析,根据指令替换数据,更新视图。
  • 3、实现一个Watcher,用来连接Obverser和Compile, 并为每个属性绑定相应的订阅者,当数据发生变化时,执行相应的回调函数,从而更新视图。

Vue中的Observer:

首先是Observer对象,源码位置src/core/observer/index.js

  1. export class Observer {
  2. value: any;
  3. dep: Dep;
  4. vmCount: number;
  5. constructor (value: any) {
  6. this.value = value
  7. this.dep = new Dep()
  8. this.vmCount = 0
  9. // 添加__ob__来标示value有对应的Observer
  10. def(value, '__ob__', this)
  11. if (Array.isArray(value)) { // 处理数组
  12. if (hasProto) { // 实现是'__proto__' in {}
  13. protoAugment(value, arrayMethods)
  14. } else {
  15. copyAugment(value, arrayMethods, arrayKeys)
  16. }
  17. this.observeArray(value)
  18. } else { // 处理对象
  19. this.walk(value)
  20. }
  21. }
  22. // 给对象每个属性添加getter/setters
  23. walk (obj: Object) {
  24. const keys = Object.keys(obj)
  25. for (let i = 0; i < keys.length; i++) {
  26. defineReactive(obj, keys[i]) // 重点
  27. }
  28. }
  29. // 循环观察数组的每一项
  30. observeArray (items: Array<any>) {
  31. for (let i = 0, l = items.length; i < l; i++) {
  32. observe(items[i]) // 重点
  33. }
  34. }
  35. }

整体上,value分为对象或数组两种情况来处理。这里我们先来看看defineReactive和observe这两个比较重要的函数。

  1. export function defineReactive (
  2. obj: Object,
  3. key: string,
  4. val: any,
  5. customSetter?: ?Function,
  6. shallow?: boolean
  7. ) {
  8. const dep = new Dep()
  9. const property = Object.getOwnPropertyDescriptor(obj, key)
  10. // 带有不可配置的属性直接跳过
  11. if (property && property.configurable === false) {
  12. return
  13. }
  14. // cater for pre-defined getter/setters
  15. // 保存对象属性上自有的getter和setter
  16. const getter = property && property.get
  17. const setter = property && property.set
  18. // 如果属性上之前没有定义getter,并且没有传入初始val值,就把属性原有的值赋值给val
  19. if ((!getter || setter) && arguments.length === 2) {
  20. val = obj[key]
  21. }
  22. let childOb = !shallow && observe(val)
  23. Object.defineProperty(obj, key, {
  24. enumerable: true,
  25. configurable: true,
  26. get: function reactiveGetter () {
  27. // 给属性设置getter
  28. const value = getter ? getter.call(obj) : val
  29. if (Dep.target) {
  30. // 给每个属性创建一个dep
  31. dep.depend()
  32. if (childOb) {
  33. childOb.dep.depend()
  34. // 如果是数组,就递归创建
  35. if (Array.isArray(value)) {
  36. dependArray(value)
  37. }
  38. }
  39. }
  40. return value
  41. },
  42. set: function reactiveSetter (newVal) {
  43. // 给属性设置setter
  44. const value = getter ? getter.call(obj) : val
  45. // 值未变化,就跳过
  46. if (newVal === value || (newVal !== newVal && value !== value)) {
  47. return
  48. }
  49. if (process.env.NODE_ENV !== 'production' && customSetter) {
  50. customSetter() // 非生产环境自定义调试用,这里忽略
  51. }
  52. if (getter && !setter) return
  53. if (setter) {
  54. setter.call(obj, newVal)
  55. } else {
  56. val = newVal
  57. }
  58. childOb = !shallow && observe(newVal)
  59. // 值发生变化进行通知
  60. dep.notify()
  61. }
  62. })
  63. }

defineReactive这个方法里面,是具体的为对象的属性添加getter、setter的地方。它会为每个值创建一个dep,如果用户为这个值传入getter和setter,则暂时保存。之后通过Object.defineProperty,重新添加装饰器。在getter中,dep.depend其实做了两件事,一是向Dep.target内部的deps添加dep,二是将Dep.target添加到dep内部的subs,也就是建立它们之间的联系。在setter中,如果新旧值相同,直接返回,不同则调用dep.notify来更新与之相关的watcher。

  1. export function observe (value: any, asRootData: ?boolean): Observer | void {
  2. // 如果不是对象就跳过
  3. if (!isObject(value) || value instanceof VNode) {
  4. return
  5. }
  6. let ob: Observer | void
  7. if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
  8. // 如果已有observer,就直接返回,上面讲到过会用`__ob__`属性来记录
  9. ob = value.__ob__
  10. } else if (
  11. shouldObserve &&
  12. !isServerRendering() &&
  13. (Array.isArray(value) || isPlainObject(value)) &&
  14. Object.isExtensible(value) &&
  15. !value._isVue
  16. ) {
  17. // 如果没有,就创建一个
  18. ob = new Observer(value)
  19. }
  20. if (asRootData && ob) {
  21. ob.vmCount++
  22. }
  23. return ob
  24. }

observe这个方法用于观察一个对象,返回与对象相关的Observer对象,如果没有则为value创建一个对应的Observer。

好的,我们再回到Observer,如果传入的是对象,我们就调用walk,该方法就是遍历对象,对每个值执行defineReactive。

对于传入的对象是数组的情况,其实会有一些特殊的处理,因为数组本身只引用了一个地址,所以对数组进行push、splice、sort等操作,我们是无法监听的。所以,Vue中改写value的__proto__(如果有),或在value上重新定义这些方法。augment在环境支持__proto__时是protoAugment,不支持时是copyAugment。

  1. // augment在环境支持__proto__时
  2. function protoAugment (target, src: Object) {
  3. target.__proto__ = src
  4. }
  5. // augment在环境不支持__proto__时
  6. function copyAugment (target: Object, src: Object, keys: Array<string>) {
  7. for (let i = 0, l = keys.length; i < l; i++) {
  8. const key = keys[i]
  9. def(target, key, src[key])
  10. }
  11. }

augment在环境支持__proto__时,就很简单,调用protoAugment其实就是执行了value.__proto__ = arrayMethodsaugment在环境支持__proto__时,调用copyAugment中循环把arrayMethods上的arrayKeys方法添加到value上。

那这里我们就要看看arrayMethods方法了。arrayMethods其实是改写了数组方法的新对象。arrayKeysarrayMethods中的方法列表。

  1. const arrayProto = Array.prototype
  2. export const arrayMethods = Object.create(arrayProto)
  3. const methodsToPatch = [
  4. 'push',
  5. 'pop',
  6. 'shift',
  7. 'unshift',
  8. 'splice',
  9. 'sort',
  10. 'reverse'
  11. ]
  12. methodsToPatch.forEach(function (method) {
  13. const original = arrayProto[method]
  14. def(arrayMethods, method, function mutator (...args) {
  15. const result = original.apply(this, args)
  16. const ob = this.__ob__
  17. let inserted
  18. switch (method) {
  19. case 'push':
  20. case 'unshift':
  21. inserted = args
  22. break
  23. case 'splice':
  24. inserted = args.slice(2)
  25. break
  26. }
  27. // 是push、unshift、splice时,重新观察数组,因为这三个方法都是像数组中添加新的元素
  28. if (inserted) ob.observeArray(inserted)
  29. // 通知变化
  30. ob.dep.notify()
  31. return result
  32. })
  33. })

实际上还是调用数组相应的方法来操作value,只不过操作之后,添加了相关watcher的更新。调用pushunshiftsplice三个方法参数大于2时,要重新调用ob.observeArray,因为这三种情况都是像数组中添加新的元素,所以需要重新观察每个子元素。最后在通知变化。

Vue中的Observer就讲到这里了。实际上还有两个函数setdel没有讲解,其实就是在添加或删除数组元素、对象属性时进行getter、setter的绑定以及通知变化,具体可以去看源码。

Vue中的Dep:

看完Vue中的Observer,然后我们来看看Vue中Dep,源码位置:src/core/observer/dep.js

  1. let uid = 0
  2. export default class Dep {
  3. static target: ?Watcher;
  4. id: number;
  5. subs: Array<Watcher>;
  6. constructor () {
  7. this.id = uid++
  8. this.subs = []
  9. }
  10. // 添加订阅者
  11. addSub (sub: Watcher) {
  12. this.subs.push(sub)
  13. }
  14. // 移除订阅者
  15. removeSub (sub: Watcher) {
  16. remove(this.subs, sub)
  17. }
  18. // 添加到订阅管理器
  19. depend () {
  20. if (Dep.target) {
  21. Dep.target.addDep(this)
  22. }
  23. }
  24. // 通知变化
  25. notify () {
  26. const subs = this.subs.slice()
  27. if (process.env.NODE_ENV !== 'production' && !config.async) {
  28. subs.sort((a, b) => a.id - b.id)
  29. }
  30. // 遍历所有的订阅者,通知更新
  31. for (let i = 0, l = subs.length; i < l; i++) {
  32. subs[i].update()
  33. }
  34. }
  35. }

Dep类就比较简单,内部有一个id和一个subs,id用于作为dep对象的唯一标识,subs就是保存watcher的数组。相比于上面我们自己实现的demo应用,这里多了removeSub和depend。removeSub是从数组中移除某个watcher,depend是调用了watcher的addDep。

好,Vue中的Dep只能说这么多了。

Vue中的Watcher:

最后我们再来看看Vue中的Watcher,源码位置:src/core/observer/watcher.js

  1. // 注,我删除了源码中一些不太重要或与双向绑定关系不太大的逻辑,删除的代码用// ... 表示
  2. let uid = 0
  3. export default class Watcher {
  4. vm: Component;
  5. expression: string;
  6. cb: Function;
  7. id: number;
  8. deep: boolean;
  9. user: boolean;
  10. lazy: boolean;
  11. sync: boolean;
  12. dirty: boolean;
  13. active: boolean;
  14. deps: Array<Dep>;
  15. newDeps: Array<Dep>;
  16. depIds: SimpleSet;
  17. newDepIds: SimpleSet;
  18. before: ?Function;
  19. getter: Function;
  20. value: any;
  21. constructor (
  22. vm: Component,
  23. expOrFn: string | Function,
  24. cb: Function,
  25. options?: ?Object,
  26. isRenderWatcher?: boolean
  27. ) {
  28. this.vm = vm
  29. if (isRenderWatcher) {
  30. vm._watcher = this
  31. }
  32. vm._watchers.push(this)
  33. // ...
  34. this.cb = cb
  35. this.id = ++uid
  36. // ...
  37. this.expression = process.env.NODE_ENV !== 'production'
  38. ? expOrFn.toString()
  39. : ''
  40. if (typeof expOrFn === 'function') {
  41. this.getter = expOrFn
  42. } else {
  43. this.getter = parsePath(expOrFn)
  44. if (!this.getter) {
  45. this.getter = noop
  46. process.env.NODE_ENV !== 'production' && warn(
  47. `Failed watching path: "${expOrFn}" ` +
  48. 'Watcher only accepts simple dot-delimited paths. ' +
  49. 'For full control, use a function instead.',
  50. vm
  51. )
  52. }
  53. }
  54. this.value = this.lazy
  55. ? undefined
  56. : this.get()
  57. }
  58. get () {
  59. pushTarget(this)
  60. let value
  61. const vm = this.vm
  62. // ...
  63. if (this.deep) {
  64. traverse(value)
  65. }
  66. popTarget()
  67. this.cleanupDeps()
  68. return value
  69. }
  70. addDep (dep: Dep) {
  71. const id = dep.id
  72. if (!this.newDepIds.has(id)) {
  73. this.newDepIds.add(id)
  74. this.newDeps.push(dep)
  75. if (!this.depIds.has(id)) {
  76. dep.addSub(this)
  77. }
  78. }
  79. }
  80. cleanupDeps () {
  81. // ...
  82. }
  83. update () {
  84. // 更新三种模式吧,lazy延迟更新,sync同步更新直接执行,默认异步更新添加到处理队列
  85. if (this.lazy) {
  86. this.dirty = true
  87. } else if (this.sync) {
  88. this.run()
  89. } else {
  90. queueWatcher(this)
  91. }
  92. }
  93. run () {
  94. // 触发更新,在这里调用cb函数
  95. if (this.active) {
  96. const value = this.get()
  97. if (
  98. value !== this.value ||
  99. isObject(value) ||
  100. this.deep
  101. ) {
  102. const oldValue = this.value
  103. this.value = value
  104. if (this.user) {
  105. try {
  106. this.cb.call(this.vm, value, oldValue)
  107. } catch (e) {
  108. handleError(e, this.vm, `callback for watcher "${this.expression}"`)
  109. }
  110. } else {
  111. this.cb.call(this.vm, value, oldValue)
  112. }
  113. }
  114. }
  115. }
  116. evaluate () {
  117. // ...
  118. }
  119. depend () {
  120. let i = this.deps.length
  121. while (i--) {
  122. this.deps[i].depend()
  123. }
  124. }
  125. teardown () {
  126. // ...
  127. }
  128. }

创建Watcher对象时,有两个比较重要的参数,一个是expOrFn,一个是cb。

在Watcher创建时,会调用this.get,里面会执行根据expOrFn解析出来的getter。在这个getter中,我们或渲染页面,或获取某个数据的值。总之,会调用相关data的getter,来建立数据的双向绑定。

当相关的数据改变时,会调用watcher的update方法,进而调用run方法。我们看到,run中还会调用this.get来获取修改之后的value值。

其实Watcher有两种主要用途:一种是更新模板,另一种就是监听某个值的变化。

模板更新的情况:在Vue声明周期挂载元素时,我们是通过创建Watcher对象,然后调用updateComponent来更新渲染模板的。

  1. vm._watcher = new Watcher(vm, updateComponent, noop)

在创建Watcher会调用this.get,也就是这里的updateComponent。在render的过程中,会调用data的getter方法,以此来建立数据的双向绑定,当数据改变时,会重新触发updateComponent。

数据监听的情况:另一个用途就是我们的computed、watch等,即监听数据的变化来执行响应的操作。此时this.get返回的是要监听数据的值。初始化过程中,调用this.get会拿到初始值保存为this.value,监听的数据改变后,会再次调用this.get并拿到修改之后的值,将旧值和新值传给cb并执行响应的回调。

好,Vue中的Watcher就说这么多了。其实上面注释的代码中还有cleanupDeps清除依赖逻辑、teardown销毁Watcher逻辑等,留给大家自己去看源码吧。

总结一下

Vue中双向绑定,简单来说就是Observer、Watcher、Dep三部分。下面我们再梳理一下整个过程:

首先我们为每个vue属性用Object.defineProperty()实现数据劫持,为每个属性分配一个订阅者集合的管理数组dep;

然后在编译的时候在该属性的数组dep中添加订阅者,Vue中的v-model会添加一个订阅者,{{}}也会,v-bind也会;

最后修改值就会为该属性赋值,触发该属性的set方法,在set方法内通知订阅者数组dep,订阅者数组循环调用各订阅者的update方法更新视图。

相关

[Vue源码]一起来学Vue双向绑定原理-数据劫持和发布订阅的更多相关文章

  1. [Vue源码]一起来学Vue模板编译原理(一)-Template生成AST

    本文我们一起通过学习Vue模板编译原理(一)-Template生成AST来分析Vue源码.预计接下来会围绕Vue源码来整理一些文章,如下. 一起来学Vue双向绑定原理-数据劫持和发布订阅 一起来学Vu ...

  2. [Vue源码]一起来学Vue模板编译原理(二)-AST生成Render字符串

    本文我们一起通过学习Vue模板编译原理(二)-AST生成Render字符串来分析Vue源码.预计接下来会围绕Vue源码来整理一些文章,如下. 一起来学Vue双向绑定原理-数据劫持和发布订阅 一起来学V ...

  3. Vue源码分析(一) : new Vue() 做了什么

    Vue源码分析(一) : new Vue() 做了什么 author: @TiffanysBear 在了解new Vue做了什么之前,我们先对Vue源码做一些基础的了解,如果你已经对基础的源码目录设计 ...

  4. Vue 源码解读(3)—— 响应式原理

    前言 上一篇文章 Vue 源码解读(2)-- Vue 初始化过程 详细讲解了 Vue 的初始化过程,明白了 new Vue(options) 都做了什么,其中关于 数据响应式 的实现用一句话简单的带过 ...

  5. Vue源码学习(零):内部原理解析

    本篇文章是在阅读<剖析 Vue.js 内部运行机制>小册子后总结所得,想要了解详细内容,请参考原文:https://juejin.im/book/5a36661851882538e2259 ...

  6. 从Vue源码中我学到了几点精妙方法

    话不多说,赶快试试这几个精妙方法吧!在工作中肯定会用得到. 立即执行函数 页面加载完成后只执行一次的设置函数. (function (a, b) { console.log(a, b); // 1,2 ...

  7. vue源码分析之new Vue过程

    实例化构造函数 从这里可以看出new Vue实际上是使vue构造函数实例化,然后调用_init方法 _init方法,该方法在 src/core/instance/init.js 中定义 Vue.pro ...

  8. Vue源码思维导图------------Vue选项的合并之$options

    本节将看下初始化中的$options: Vue.prototype._init = function (options?: Object) { const vm: Component = this / ...

  9. Vue源码解析---数据的双向绑定

    本文主要抽离Vue源码中数据双向绑定的核心代码,解析Vue是如何实现数据的双向绑定 核心思想是ES5的Object.defineProperty()和发布-订阅模式 整体结构 改造Vue实例中的dat ...

随机推荐

  1. nodeJS菜鸟教程笔记

    http模块 var http = require('http'); // 引入http模块 var url = require('url'); // 引入url模块 var querystring ...

  2. 利用MongoDB进行地理坐标查询

    BS的应用在生活中已经非常常见,我们打车,叫外卖,查个地图之类的都会查询附近的相关坐标位置,mongodb提供了原生的二维地图查询,极大地方便了大家的开发. 假定我们有一个定义了位置信息的集合loca ...

  3. 欢迎来到L T X的博客 & 博客转型公告

    这里是L T X,一位来自重庆的学生的个人博客. 由于博主以前是OIer,目前博客里主要是OI相关的内容. 但是现在博主已经退役了,因此博客将会转向...嗯...那种...就是那种...比较奇怪的类型 ...

  4. 将字符串转换为double类型的list

    var data=“3.039,3.977,3.677,5.855,12.341,6.771”; 方法一: var result=datas.Split(',').ToList().ConvertAl ...

  5. vue formatter element表格处理数据

    formatter 指定一个vue methods 方法 作用:对从数据库中取出的数据进行处理后展示. <el-table-column prop="partner1" // ...

  6. Spring5源码阅读环境搭建-gradle构建编译

      前沿:Spring系列生态十分丰富,涉及到各个方面.但是作为Spring生态的核心基础Spring,是最重要的环节,需要理解Spring的设计原理,我们需要解读源码.   在构建Spring源码阅 ...

  7. ECMAScript基本语法——③数据类型

    Java内有两种 基本数据类型:4类八种 引用数据类型:对象 JavaScript也有两种 原始数据类型 其实是基本数据类型 number:数字.整数.小数.NaN(特殊的数字,not a numbe ...

  8. Linux忘记 root密码的解决办法

    很多朋友经常会忘记Linux系统的root密码,linux系统忘记root密码的情况该怎么办呢?重新安装系统吗?当然不用!进入单用户模式更改一下root密码即可. 步骤如下: 重启linux系统 重启 ...

  9. 运筹学学报-运行问题之新版TeX系统支持修改

    <运筹学学报>的LaTeX模板基本上是CCT的典型而且是停留在LaTeX2.09 的时代,故而很多用户下载其模板无法在新TeX系统里使用,这里提供以下解决方案.源文件中的前几行:\docu ...

  10. Linux - Shell - 替换文件名中的空格

    概述 使用 shell 替换 文件名中的空格 背景 尝试用 find 配合 xargs, 在多个文件里找关键字 出现了问题 有空格的文件名, 并不是很好处理 准备 os centos7 1. 问题: ...