Vue 原始碼解讀(8)—— 編譯器 之 解析(下)

李永寧發表於2022-03-03

特殊說明

由於文章篇幅限制,所以將 Vue 原始碼解讀(8)—— 編譯器 之 解析 拆成了兩篇文章,本篇是對 Vue 原始碼解讀(8)—— 編譯器 之 解析(上) 的一個補充,所以在閱讀時請同時開啟 Vue 原始碼解讀(8)—— 編譯器 之 解析(上) 一起閱讀。

processAttrs

/src/compiler/parser/index.js

/**
 * 處理元素上的所有屬性:
 * v-bind 指令變成:el.attrs 或 el.dynamicAttrs = [{ name, value, start, end, dynamic }, ...],
 *                或者是必須使用 props 的屬性,變成了 el.props = [{ name, value, start, end, dynamic }, ...]
 * v-on 指令變成:el.events 或 el.nativeEvents = { name: [{ value, start, end, modifiers, dynamic }, ...] }
 * 其它指令:el.directives = [{name, rawName, value, arg, isDynamicArg, modifier, start, end }, ...]
 * 原生屬性:el.attrs = [{ name, value, start, end }],或者一些必須使用 props 的屬性,變成了:
 *         el.props = [{ name, value: true, start, end, dynamic }]
 */
function processAttrs(el) {
  // list = [{ name, value, start, end }, ...]
  const list = el.attrsList
  let i, l, name, rawName, value, modifiers, syncGen, isDynamic
  for (i = 0, l = list.length; i < l; i++) {
    // 屬性名
    name = rawName = list[i].name
    // 屬性值
    value = list[i].value
    if (dirRE.test(name)) {
      // 說明該屬性是一個指令

      // 元素上存在指令,將元素標記動態元素
      // mark element as dynamic
      el.hasBindings = true
      // modifiers,在屬性名上解析修飾符,比如 xx.lazy
      modifiers = parseModifiers(name.replace(dirRE, ''))
      // support .foo shorthand syntax for the .prop modifier
      if (process.env.VBIND_PROP_SHORTHAND && propBindRE.test(name)) {
        // 為 .props 修飾符支援 .foo 速記寫法
        (modifiers || (modifiers = {})).prop = true
        name = `.` + name.slice(1).replace(modifierRE, '')
      } else if (modifiers) {
        // 屬性中的修飾符去掉,得到一個乾淨的屬性名
        name = name.replace(modifierRE, '')
      }
      if (bindRE.test(name)) { // v-bind, <div :id="test"></div>
        // 處理 v-bind 指令屬性,最後得到 el.attrs 或者 el.dynamicAttrs = [{ name, value, start, end, dynamic }, ...]

        // 屬性名,比如:id
        name = name.replace(bindRE, '')
        // 屬性值,比如:test
        value = parseFilters(value)
        // 是否為動態屬性 <div :[id]="test"></div>
        isDynamic = dynamicArgRE.test(name)
        if (isDynamic) {
          // 如果是動態屬性,則去掉屬性兩側的方括號 []
          name = name.slice(1, -1)
        }
        // 提示,動態屬性值不能為空字串
        if (
          process.env.NODE_ENV !== 'production' &&
          value.trim().length === 0
        ) {
          warn(
            `The value for a v-bind expression cannot be empty. Found in "v-bind:${name}"`
          )
        }
        // 存在修飾符
        if (modifiers) {
          if (modifiers.prop && !isDynamic) {
            name = camelize(name)
            if (name === 'innerHtml') name = 'innerHTML'
          }
          if (modifiers.camel && !isDynamic) {
            name = camelize(name)
          }
          // 處理 sync 修飾符
          if (modifiers.sync) {
            syncGen = genAssignmentCode(value, `$event`)
            if (!isDynamic) {
              addHandler(
                el,
                `update:${camelize(name)}`,
                syncGen,
                null,
                false,
                warn,
                list[i]
              )
              if (hyphenate(name) !== camelize(name)) {
                addHandler(
                  el,
                  `update:${hyphenate(name)}`,
                  syncGen,
                  null,
                  false,
                  warn,
                  list[i]
                )
              }
            } else {
              // handler w/ dynamic event name
              addHandler(
                el,
                `"update:"+(${name})`,
                syncGen,
                null,
                false,
                warn,
                list[i],
                true // dynamic
              )
            }
          }
        }
        if ((modifiers && modifiers.prop) || (
          !el.component && platformMustUseProp(el.tag, el.attrsMap.type, name)
        )) {
          // 將屬性物件新增到 el.props 陣列中,表示這些屬性必須通過 props 設定
          // el.props = [{ name, value, start, end, dynamic }, ...]
          addProp(el, name, value, list[i], isDynamic)
        } else {
          // 將屬性新增到 el.attrs 陣列或者 el.dynamicAttrs 陣列
          addAttr(el, name, value, list[i], isDynamic)
        }
      } else if (onRE.test(name)) { // v-on, 處理事件,<div @click="test"></div>
        // 屬性名,即事件名
        name = name.replace(onRE, '')
        // 是否為動態屬性
        isDynamic = dynamicArgRE.test(name)
        if (isDynamic) {
          // 動態屬性,則獲取 [] 中的屬性名
          name = name.slice(1, -1)
        }
        // 處理事件屬性,將屬性的資訊新增到 el.events 或者 el.nativeEvents 物件上,格式:
        // el.events = [{ value, start, end, modifiers, dynamic }, ...]
        addHandler(el, name, value, modifiers, false, warn, list[i], isDynamic)
      } else { // normal directives,其它的普通指令
        // 得到 el.directives = [{name, rawName, value, arg, isDynamicArg, modifier, start, end }, ...]
        name = name.replace(dirRE, '')
        // parse arg
        const argMatch = name.match(argRE)
        let arg = argMatch && argMatch[1]
        isDynamic = false
        if (arg) {
          name = name.slice(0, -(arg.length + 1))
          if (dynamicArgRE.test(arg)) {
            arg = arg.slice(1, -1)
            isDynamic = true
          }
        }
        addDirective(el, name, rawName, value, arg, isDynamic, modifiers, list[i])
        if (process.env.NODE_ENV !== 'production' && name === 'model') {
          checkForAliasModel(el, value)
        }
      }
    } else {
      // 當前屬性不是指令
      // literal attribute
      if (process.env.NODE_ENV !== 'production') {
        const res = parseText(value, delimiters)
        if (res) {
          warn(
            `${name}="${value}": ` +
            'Interpolation inside attributes has been removed. ' +
            'Use v-bind or the colon shorthand instead. For example, ' +
            'instead of <div id="{{ val }}">, use <div :id="val">.',
            list[i]
          )
        }
      }
      // 將屬性物件放到 el.attrs 陣列中,el.attrs = [{ name, value, start, end }]
      addAttr(el, name, JSON.stringify(value), list[i])
      // #6887 firefox doesn't update muted state if set via attribute
      // even immediately after element creation
      if (!el.component &&
        name === 'muted' &&
        platformMustUseProp(el.tag, el.attrsMap.type, name)) {
        addProp(el, name, 'true', list[i])
      }
    }
  }
}

addHandler

/src/compiler/helpers.js

/**
 * 處理事件屬性,將事件屬性新增到 el.events 物件或者 el.nativeEvents 物件中,格式:
 * el.events[name] = [{ value, start, end, modifiers, dynamic }, ...]
 * 其中用了大量的篇幅在處理 name 屬性帶修飾符 (modifier) 的情況
 * @param {*} el ast 物件
 * @param {*} name 屬性名,即事件名
 * @param {*} value 屬性值,即事件回撥函式名
 * @param {*} modifiers 修飾符
 * @param {*} important 
 * @param {*} warn 日誌
 * @param {*} range 
 * @param {*} dynamic 屬性名是否為動態屬性
 */
export function addHandler (
  el: ASTElement,
  name: string,
  value: string,
  modifiers: ?ASTModifiers,
  important?: boolean,
  warn?: ?Function,
  range?: Range,
  dynamic?: boolean
) {
  // modifiers 是一個物件,如果傳遞的引數為空,則給一個凍結的空物件
  modifiers = modifiers || emptyObject
  // 提示:prevent 和 passive 修飾符不能一起使用
  // warn prevent and passive modifier
  /* istanbul ignore if */
  if (
    process.env.NODE_ENV !== 'production' && warn &&
    modifiers.prevent && modifiers.passive
  ) {
    warn(
      'passive and prevent can\'t be used together. ' +
      'Passive handler can\'t prevent default event.',
      range
    )
  }

  // 標準化 click.right 和 click.middle,它們實際上不會被真正的觸發,從技術講他們是它們
  // 是特定於瀏覽器的,但至少目前位置只有瀏覽器才具有右鍵和中間鍵的點選
  // normalize click.right and click.middle since they don't actually fire
  // this is technically browser-specific, but at least for now browsers are
  // the only target envs that have right/middle clicks.
  if (modifiers.right) {
    // 右鍵
    if (dynamic) {
      // 動態屬性
      name = `(${name})==='click'?'contextmenu':(${name})`
    } else if (name === 'click') {
      // 非動態屬性,name = contextmenu
      name = 'contextmenu'
      // 刪除修飾符中的 right 屬性
      delete modifiers.right
    }
  } else if (modifiers.middle) {
    // 中間鍵
    if (dynamic) {
      // 動態屬性,name => mouseup 或者 ${name}
      name = `(${name})==='click'?'mouseup':(${name})`
    } else if (name === 'click') {
      // 非動態屬性,mouseup
      name = 'mouseup'
    }
  }

  /**
   * 處理 capture、once、passive 這三個修飾符,通過給 name 新增不同的標記來標記這些修飾符
   */
  // check capture modifier
  if (modifiers.capture) {
    delete modifiers.capture
    // 給帶有 capture 修飾符的屬性,加上 ! 標記
    name = prependModifierMarker('!', name, dynamic)
  }
  if (modifiers.once) {
    delete modifiers.once
    // once 修飾符加 ~ 標記
    name = prependModifierMarker('~', name, dynamic)
  }
  /* istanbul ignore if */
  if (modifiers.passive) {
    delete modifiers.passive
    // passive 修飾符加 & 標記
    name = prependModifierMarker('&', name, dynamic)
  }

  let events
  if (modifiers.native) {
    // native 修飾符, 監聽元件根元素的原生事件,將事件資訊存放到 el.nativeEvents 物件中
    delete modifiers.native
    events = el.nativeEvents || (el.nativeEvents = {})
  } else {
    events = el.events || (el.events = {})
  }

  const newHandler: any = rangeSetItem({ value: value.trim(), dynamic }, range)
  if (modifiers !== emptyObject) {
    // 說明有修飾符,將修飾符物件放到 newHandler 物件上
    // { value, dynamic, start, end, modifiers }
    newHandler.modifiers = modifiers
  }

  // 將配置物件放到 events[name] = [newHander, handler, ...]
  const handlers = events[name]
  /* istanbul ignore if */
  if (Array.isArray(handlers)) {
    important ? handlers.unshift(newHandler) : handlers.push(newHandler)
  } else if (handlers) {
    events[name] = important ? [newHandler, handlers] : [handlers, newHandler]
  } else {
    events[name] = newHandler
  }

  el.plain = false
}

addIfCondition

/src/compiler/parser/index.js

/**
 * 將傳遞進來的條件物件放進 el.ifConditions 陣列中
 */
export function addIfCondition(el: ASTElement, condition: ASTIfCondition) {
  if (!el.ifConditions) {
    el.ifConditions = []
  }
  el.ifConditions.push(condition)
}

processPre

/src/compiler/parser/index.js

/**
 * 如果元素上存在 v-pre 指令,則設定 el.pre = true 
 */
function processPre(el) {
  if (getAndRemoveAttr(el, 'v-pre') != null) {
    el.pre = true
  }
}

processRawAttrs

/src/compiler/parser/index.js

/**
 * 設定 el.attrs 陣列物件,每個元素都是一個屬性物件 { name: attrName, value: attrVal, start, end }
 */
function processRawAttrs(el) {
  const list = el.attrsList
  const len = list.length
  if (len) {
    const attrs: Array<ASTAttr> = el.attrs = new Array(len)
    for (let i = 0; i < len; i++) {
      attrs[i] = {
        name: list[i].name,
        value: JSON.stringify(list[i].value)
      }
      if (list[i].start != null) {
        attrs[i].start = list[i].start
        attrs[i].end = list[i].end
      }
    }
  } else if (!el.pre) {
    // non root node in pre blocks with no attributes
    el.plain = true
  }
}

processIf

/src/compiler/parser/index.js

/**
 * 處理 v-if、v-else-if、v-else
 * 得到 el.if = "exp",el.elseif = exp, el.else = true
 * v-if 屬性會額外在 el.ifConditions 陣列中新增 { exp, block } 物件
 */
function processIf(el) {
  // 獲取 v-if 屬性的值,比如 <div v-if="test"></div>
  const exp = getAndRemoveAttr(el, 'v-if')
  if (exp) {
    // el.if = "test"
    el.if = exp
    // 在 el.ifConditions 陣列中新增 { exp, block }
    addIfCondition(el, {
      exp: exp,
      block: el
    })
  } else {
    // 處理 v-else,得到 el.else = true
    if (getAndRemoveAttr(el, 'v-else') != null) {
      el.else = true
    }
    // 處理 v-else-if,得到 el.elseif = exp
    const elseif = getAndRemoveAttr(el, 'v-else-if')
    if (elseif) {
      el.elseif = elseif
    }
  }
}

processOnce

/src/compiler/parser/index.js

/**
 * 處理 v-once 指令,得到 el.once = true
 * @param {*} el 
 */
function processOnce(el) {
  const once = getAndRemoveAttr(el, 'v-once')
  if (once != null) {
    el.once = true
  }
}

checkRootConstraints

/src/compiler/parser/index.js

/**
 * 檢查根元素:
 *   不能使用 slot 和 template 標籤作為元件的根元素
 *   不能在有狀態元件的 根元素 上使用 v-for 指令,因為它會渲染出多個元素
 * @param {*} el 
 */
function checkRootConstraints(el) {
  // 不能使用 slot 和 template 標籤作為元件的根元素
  if (el.tag === 'slot' || el.tag === 'template') {
    warnOnce(
      `Cannot use <${el.tag}> as component root element because it may ` +
      'contain multiple nodes.',
      { start: el.start }
    )
  }
  // 不能在有狀態元件的 根元素 上使用 v-for,因為它會渲染出多個元素
  if (el.attrsMap.hasOwnProperty('v-for')) {
    warnOnce(
      'Cannot use v-for on stateful component root element because ' +
      'it renders multiple elements.',
      el.rawAttrsMap['v-for']
    )
  }
}

closeElement

/src/compiler/parser/index.js

/**
 * 主要做了 3 件事:
 *   1、如果元素沒有被處理過,即 el.processed 為 false,則呼叫 processElement 方法處理節點上的眾多屬性
 *   2、讓自己和父元素產生關係,將自己放到父元素的 children 陣列中,並設定自己的 parent 屬性為 currentParent
 *   3、設定自己的子元素,將自己所有非插槽的子元素放到自己的 children 陣列中
 */
function closeElement(element) {
  // 移除節點末尾的空格,當前 pre 標籤內的元素除外
  trimEndingWhitespace(element)
  // 當前元素不再 pre 節點內,並且也沒有被處理過
  if (!inVPre && !element.processed) {
    // 分別處理元素節點的 key、ref、插槽、自閉合的 slot 標籤、動態元件、class、style、v-bind、v-on、其它指令和一些原生屬性 
    element = processElement(element, options)
  }
  // 處理根節點上存在 v-if、v-else-if、v-else 指令的情況
  // 如果根節點存在 v-if 指令,則必須還提供一個具有 v-else-if 或者 v-else 的同級別節點,防止根元素不存在
  // tree management
  if (!stack.length && element !== root) {
    // allow root elements with v-if, v-else-if and v-else
    if (root.if && (element.elseif || element.else)) {
      if (process.env.NODE_ENV !== 'production') {
        // 檢查根元素
        checkRootConstraints(element)
      }
      // 給根元素設定 ifConditions 屬性,root.ifConditions = [{ exp: element.elseif, block: element }, ...]
      addIfCondition(root, {
        exp: element.elseif,
        block: element
      })
    } else if (process.env.NODE_ENV !== 'production') {
      // 提示,表示不應該在 根元素 上只使用 v-if,應該將 v-if、v-else-if 一起使用,保證元件只有一個根元素
      warnOnce(
        `Component template should contain exactly one root element. ` +
        `If you are using v-if on multiple elements, ` +
        `use v-else-if to chain them instead.`,
        { start: element.start }
      )
    }
  }
  // 讓自己和父元素產生關係
  // 將自己放到父元素的 children 陣列中,然後設定自己的 parent 屬性為 currentParent
  if (currentParent && !element.forbidden) {
    if (element.elseif || element.else) {
      processIfConditions(element, currentParent)
    } else {
      if (element.slotScope) {
        // scoped slot
        // keep it in the children list so that v-else(-if) conditions can
        // find it as the prev node.
        const name = element.slotTarget || '"default"'
          ; (currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
      }
      currentParent.children.push(element)
      element.parent = currentParent
    }
  }

  // 設定自己的子元素
  // 將自己的所有非插槽的子元素設定到 element.children 陣列中
  // final children cleanup
  // filter out scoped slots
  element.children = element.children.filter(c => !(c: any).slotScope)
  // remove trailing whitespace node again
  trimEndingWhitespace(element)

  // check pre state
  if (element.pre) {
    inVPre = false
  }
  if (platformIsPreTag(element.tag)) {
    inPre = false
  }
  // 分別為 element 執行 model、class、style 三個模組的 postTransform 方法
  // 但是 web 平臺沒有提供該方法
  // apply post-transforms
  for (let i = 0; i < postTransforms.length; i++) {
    postTransforms[i](element, options)
  }
}

trimEndingWhitespace

/src/compiler/parser/index.js

/**
 * 刪除元素中空白的文字節點,比如:<div> </div>,刪除 div 元素中的空白節點,將其從元素的 children 屬性中移出去
 */
function trimEndingWhitespace(el) {
  if (!inPre) {
    let lastNode
    while (
      (lastNode = el.children[el.children.length - 1]) &&
      lastNode.type === 3 &&
      lastNode.text === ' '
    ) {
      el.children.pop()
    }
  }
}

processIfConditions

/src/compiler/parser/index.js

function processIfConditions(el, parent) {
  // 找到 parent.children 中的最後一個元素節點
  const prev = findPrevElement(parent.children)
  if (prev && prev.if) {
    addIfCondition(prev, {
      exp: el.elseif,
      block: el
    })
  } else if (process.env.NODE_ENV !== 'production') {
    warn(
      `v-${el.elseif ? ('else-if="' + el.elseif + '"') : 'else'} ` +
      `used on element <${el.tag}> without corresponding v-if.`,
      el.rawAttrsMap[el.elseif ? 'v-else-if' : 'v-else']
    )
  }
}

findPrevElement

/src/compiler/parser/index.js

/**
 * 找到 children 中的最後一個元素節點 
 */
function findPrevElement(children: Array<any>): ASTElement | void {
  let i = children.length
  while (i--) {
    if (children[i].type === 1) {
      return children[i]
    } else {
      if (process.env.NODE_ENV !== 'production' && children[i].text !== ' ') {
        warn(
          `text "${children[i].text.trim()}" between v-if and v-else(-if) ` +
          `will be ignored.`,
          children[i]
        )
      }
      children.pop()
    }
  }
}

幫助

到這裡編譯器的解析部分就結束了,相信很多人看的是雲裡霧裡的,即使多看幾遍可能也沒有那麼清晰。

不要著急,這個很正常,編譯器這塊兒的程式碼量確實是比較大。但是內容本身其實不復雜,複雜的是它要處理東西實在是太多了,這才導致這部分的程式碼量巨大,相對應的,就會產生比較難的感覺。確實不簡單,至少我覺得它是整個框架最複雜最難的地方了。

對照著視訊和文章大家可以多看幾遍,不明白的地方寫一些示例程式碼輔助除錯,編寫詳細的註釋。還是那句話,書讀百遍,其義自現。

閱讀的過程中,大家需要抓住編譯器解析部分的本質:將類 HTML 字串模版解析成 AST 物件。

所以這麼多程式碼都在做一件事情,就是解析字串模版,將整個模版用 AST 物件來表示和記錄。所以,大家閱讀的時候,可以將解析過程中生成的 AST 物件記錄下來,幫助閱讀和理解,這樣在讀完以後不至於那麼迷茫,也有助於大家理解。

這是我在閱讀的時候的一個簡單記錄:

const element = {
  type: 1,
  tag,
  attrsList: [{ name: attrName, value: attrVal, start, end }],
  attrsMap: { attrName: attrVal, },
  rawAttrsMap: { attrName: attrVal, type: checkbox },
  // v-if
  ifConditions: [{ exp, block }],
  // v-for
  for: iterator,
  alias: 別名,
  // :key
  key: xx,
  // ref
  ref: xx,
  refInFor: boolean,
  // 插槽
  slotTarget: slotName,
  slotTargetDynamic: boolean,
  slotScope: 作用域插槽的表示式,
  scopeSlot: {
    name: {
      slotTarget: slotName,
      slotTargetDynamic: boolean,
      children: {
        parent: container,
        otherProperty,
      }
    },
    slotScope: 作用域插槽的表示式,
  },
  slotName: xx,
  // 動態元件
  component: compName,
  inlineTemplate: boolean,
  // class
  staticClass: className,
  classBinding: xx,
  // style
  staticStyle: xx,
  styleBinding: xx,
  // attr
  hasBindings: boolean,
  nativeEvents: {同 evetns},
  events: {
    name: [{ value, dynamic, start, end, modifiers }]
  },
  props: [{ name, value, dynamic, start, end }],
  dynamicAttrs: [同 attrs],
  attrs: [{ name, value, dynamic, start, end }],
  directives: [{ name, rawName, value, arg, isDynamicArg, modifiers, start, end }],
  // v-pre
  pre: true,
  // v-once
  once: true,
  parent,
  children: [],
  plain: boolean,
}

總結

  • 面試官 問:簡單說一下 Vue 的編譯器都做了什麼?

    Vue 的編譯器做了三件事情:

    • 將元件的 html 模版解析成 AST 物件

    • 優化,遍歷 AST,為每個節點做靜態標記,標記其是否為靜態節點,然後進一步標記出靜態根節點,這樣在後續更新的過程中就可以跳過這些靜態節點了;標記靜態根用於生成渲染函式階段,生成靜態根節點的渲染函式

    • 從 AST 生成執行時的渲染函式,即大家說的 render,其實還有一個,就是 staticRenderFns 陣列,裡面存放了所有的靜態節點的渲染函式


  • 面試官 問:詳細說一說編譯器的解析過程,它是怎麼將 html 字串模版變成 AST 物件的?

    • 遍歷 HTML 模版字串,通過正規表示式匹配 "<"

    • 跳過某些不需要處理的標籤,比如:註釋標籤、條件註釋標籤、Doctype。

      備註:整個解析過程的核心是處理開始標籤和結束標籤

    • 解析開始標籤

      • 得到一個物件,包括 標籤名(tagName)、所有的屬性(attrs)、標籤在 html 模版字串中的索引位置

      • 進一步處理上一步得到的 attrs 屬性,將其變成 [{ name: attrName, value: attrVal, start: xx, end: xx }, ...] 的形式

      • 通過標籤名、屬性物件和當前元素的父元素生成 AST 物件,其實就是一個 普通的 JS 物件,通過 key、value 的形式記錄了該元素的一些資訊

      • 接下來進一步處理開始標籤上的一些指令,比如 v-pre、v-for、v-if、v-once,並將處理結果放到 AST 物件上

      • 處理結束將 ast 物件存放到 stack 陣列

      • 處理完成後會截斷 html 字串,將已經處理掉的字串截掉

    • 解析閉合標籤

      • 如果匹配到結束標籤,就從 stack 陣列中拿出最後一個元素,它和當前匹配到的結束標籤是一對。

      • 再次處理開始標籤上的屬性,這些屬性和前面處理的不一樣,比如:key、ref、scopedSlot、樣式等,並將處理結果放到元素的 AST 物件上

        備註 視訊中說這塊兒有誤,回頭看了下,沒有問題,不需要改,確實是這樣

      • 然後將當前元素和父元素產生聯絡,給當前元素的 ast 物件設定 parent 屬性,然後將自己放到父元素的 ast 物件的 children 陣列中

    • 最後遍歷完整個 html 模版字串以後,返回 ast 物件

連結

感謝各位的:關注點贊收藏評論,我們下期見。


當學習成為了習慣,知識也就變成了常識。 感謝各位的 關注點贊收藏評論

新視訊和文章會第一時間在微信公眾號傳送,歡迎關注:李永寧lyn

文章已收錄到 github 倉庫 liyongning/blog,歡迎 Watch 和 Star。

相關文章