React Fiber 原始碼解析

雲音樂大前端團隊發表於2020-08-11

圖片作者:Artem Sapegin,來源:https://unsplash.com/photos/b...

本文作者:劉鵬

前言

在 React v16.13 版本中,正式推出了實驗性的 Concurrent Mode,尤其是提供一種新的機制 Suspense,非常自然地解決了一直以來存在的非同步副作用問題。結合前面 v16.8 推出的 Hooks,v16.0 底層架構 Fiber,React 給開發者體驗上帶來了極大提升以及一定程度上更佳的使用者體驗。所以,對 React 17,你會有什麼期待?

Stack Reconciler 和  Fiber Reconciler

我們知道,Stack Reconciler 是 React v15 及之前版本使用的協調演算法。而 React Fiber 則是從 v16 版本開始對 Stack Reconciler 進行的重寫,是 v16 版本的核心演算法實現。
Stack Reconciler 的實現使用了同步遞迴模型,該模型依賴於內建堆疊來遍歷。React 團隊 Andrew 之前有提到:

如果只依賴內建呼叫堆疊,那麼它將一直工作,直到堆疊為空,如果我們可以隨意中斷呼叫堆疊並手動操作堆疊幀,這不是很好嗎? 這就是 React Fiber 的目標。Fiber 是內建堆疊的重新實現,專門用於 React 元件,可以將一個 fiber 看作是一個虛擬堆疊幀。

正是由於其內建 Stack Reconciler 天生帶來的侷限性,使得 DOM 更新過程是同步的。也就是說,在虛擬 DOM 的比對過程中,如果發現一個元素例項有更新,則會立即同步執行操作,提交到真實 DOM 的更改。這在動畫、佈局以及手勢等領域,可能會帶來非常糟糕的使用者體驗。因此,為了解決這個問題,React 實現了一個虛擬堆疊幀。實際上,這個所謂的虛擬堆疊幀本質上是建立了多個包含節點和指標的連結串列資料結構。每一個節點就是一個 fiber 基本單元,這個物件儲存了一定的元件相關的資料域資訊。而指標的指向,則是串聯起整個 fibers 樹。重新自定義堆疊帶來顯而易見的優點是,可以將堆疊保留在記憶體中,在需要執行的時候執行它們,這使得暫停遍歷和停止堆疊遞迴成為可能。

Fiber 的主要目標是實現虛擬 DOM 的增量渲染,能夠將渲染工作拆分成塊並將其分散到多個幀的能力。在新的更新到來時,能夠暫停、中止和複用工作,能為不同型別的更新分配優先順序順序的能力。理解 React 執行機制對我們更好理解它的設計思想以及後續版本新增特性,比如 v17 版本可能帶來的非同步渲染能力,相信會有很好的幫助。本文基於 React v16.8.6 版本原始碼,輸出一些淺見,希望對你也有幫助,如有不對,還望指正。

基礎概念

在瞭解 React Fiber 架構的實現機制之前,有必要先把幾個主要的基礎概念丟擲來,以便於我們更好地理解。

Work

在 React Reconciliation 過程中出現的各種必須執行計算的活動,比如 state update,props update 或 refs update 等,這些活動我們可以統一稱之為 work。

Fiber 物件

檔案位置:packages/react-reconciler/src/ReactFiber.js

每一個 React 元素對應一個 fiber 物件,一個 fiber 物件通常是表徵 work 的一個基本單元。fiber 物件有幾個屬性,這些屬性指向其他 fiber 物件。

  • child: 對應於父 fiber 節點的子 fiber
  • sibling: 對應於 fiber 節點的同類兄弟節點
  • return: 對應於子 fiber 節點的父節點

因此 fibers 可以理解為是一個包含 React 元素上下文資訊的資料域節點,以及由 child, sibling 和 return 等指標域構成的連結串列結構。

fiber 物件主要的屬性如下所示:

Fiber = {
    // 標識 fiber 型別的標籤,詳情參看下述 WorkTag
    tag: WorkTag,

    // 指向父節點
    return: Fiber | null,

    // 指向子節點
    child: Fiber | null,

    // 指向兄弟節點
    sibling: Fiber | null,

    // 在開始執行時設定 props 值
    pendingProps: any,

    // 在結束時設定的 props 值
    memoizedProps: any,

    // 當前 state
    memoizedState: any,

    // Effect 型別,詳情檢視以下 effectTag
    effectTag: SideEffectTag,

    // effect 節點指標,指向下一個 effect
    nextEffect: Fiber | null,

    // effect list 是單向連結串列,第一個 effect
    firstEffect: Fiber | null,

    // effect list 是單向連結串列,最後一個 effect
    lastEffect: Fiber | null,

    // work 的過期時間,可用於標識一個 work 優先順序順序
    expirationTime: ExpirationTime,
};

從 React 元素建立一個 fiber 物件

檔案位置:react-reconciler/src/ReactFiber.js
export function createFiberFromElement(
    element: ReactElement,
    mode: TypeOfMode,
    expirationTime: ExpirationTime
): Fiber {
    const fiber = createFiberFromTypeAndProps(type, key, pendingProps, owner, mode, expirationTime);
    return fiber;
}

workTag

檔案位置:shared/ReactWorkTags.js

上述 fiber 物件的 tag 屬性值,稱作 workTag,用於標識一個 React 元素的型別,如下所示:

export const FunctionComponent = 0;
export const ClassComponent = 1;
export const IndeterminateComponent = 2; // Before we know whether it is function or class
export const HostRoot = 3; // Root of a host tree. Could be nested inside another node.
export const HostPortal = 4; // A subtree. Could be an entry point to a different renderer.
export const HostComponent = 5;
export const HostText = 6;
export const Fragment = 7;
export const Mode = 8;
export const ContextConsumer = 9;
export const ContextProvider = 10;
export const ForwardRef = 11;
export const Profiler = 12;
export const SuspenseComponent = 13;
export const MemoComponent = 14;
export const SimpleMemoComponent = 15;
export const LazyComponent = 16;
export const IncompleteClassComponent = 17;
export const DehydratedSuspenseComponent = 18;
export const EventComponent = 19;
export const EventTarget = 20;
export const SuspenseListComponent = 21;

EffectTag

檔案位置:shared/ReactSideEffectTags.js

上述 fiber 物件的 effectTag 屬性值,每一個 fiber 節點都有一個和它相關聯的 effectTag 值。
我們把不能在 render 階段完成的一些 work 稱之為副作用,React 羅列了可能存在的各類副作用,如下所示:

export const NoEffect = /*              */ 0b000000000000;
export const PerformedWork = /*         */ 0b000000000001;

export const Placement = /*             */ 0b000000000010;
export const Update = /*                */ 0b000000000100;
export const PlacementAndUpdate = /*    */ 0b000000000110;
export const Deletion = /*              */ 0b000000001000;
export const ContentReset = /*          */ 0b000000010000;
export const Callback = /*              */ 0b000000100000;
export const DidCapture = /*            */ 0b000001000000;
export const Ref = /*                   */ 0b000010000000;
export const Snapshot = /*              */ 0b000100000000;
export const Passive = /*               */ 0b001000000000;

export const LifecycleEffectMask = /*   */ 0b001110100100;
export const HostEffectMask = /*        */ 0b001111111111;

export const Incomplete = /*            */ 0b010000000000;
export const ShouldCapture = /*         */ 0b100000000000;

Reconciliation 和 Scheduling

協調(Reconciliation):
簡而言之,根據 diff 演算法來比較虛擬 DOM,從而可以確認哪些部分的 React 元素需要更改。

排程(Scheduling):
可以簡單理解為是一個確定在什麼時候執行 work 的過程。

Render 階段和 Commit 階段

相信很多同學都看過這張圖,這是 React 團隊作者 Dan Abramov 畫的一張生命週期階段圖,詳情點選檢視。他把 React 的生命週期主要分為兩個階段:render 階段和 commit 階段。其中 commit 階段又可以細分為 pre-commit 階段和 commit 階段,如下圖所示:

image.png

從 v16.3 版本開始,在 render 階段,以下幾個生命週期被認為是不安全的,它們將在未來的版本中被移除,可以看到這些生命週期在上圖中未被包括進去,如下所示:

  • [UNSAFE_]componentWillMount (deprecated)
  • [UNSAFE_]componentWillReceiveProps (deprecated)
  • [UNSAFE_]componentWillUpdate (deprecated)

在 React 官網中明確提到了廢棄的原因,這些被標記為不安全的生命週期由於常常被開發者錯誤理解甚至被濫用,比如一些開發人員會傾向於將帶有請求資料等副作用的邏輯放在這些生命週期方法中,認為能帶來更好的效能,而實際上真正帶來的收益幾乎可以忽略。在未來, React 逐步推崇非同步渲染模式下,這很有可能會因為不相容而帶來很多問題。

在 render 階段,React 可以根據當前可用的時間片處理一個或多個 fiber 節點,並且得益於 fiber 物件中儲存的元素上下文資訊以及指標域構成的連結串列結構,使其能夠將執行到一半的工作儲存在記憶體的連結串列中。當 React 停止並完成儲存的工作後,讓出時間片去處理一些其他優先順序更高的事情。之後,在重新獲取到可用的時間片後,它能夠根據之前儲存在記憶體的上下文資訊通過快速遍歷的方式找到停止的 fiber 節點並繼續工作。由於在此階段執行的工作並不會導致任何使用者可見的更改,因為並沒有被提交到真實的 DOM。所以,我們說是 fiber 讓排程能夠實現暫停、中止以及重新開始等增量渲染的能力。相反,在 commit 階段,work 執行總是同步的,這是因為在此階段執行的工作將導致使用者可見的更改。這就是為什麼在 commit 階段, React 需要一次性提交併完成這些工作的原因。

Current 樹和 WorkInProgress 樹

首次渲染之後,React 會生成一個對應於 UI 渲染的 fiber 樹,稱之為 current 樹。實際上,React 在呼叫生命週期鉤子函式時就是通過判斷是否存在 current 來區分何時執行 componentDidMount 和 componentDidUpdate。當 React 遍歷 current 樹時,它會為每一個存在的 fiber 節點建立了一個替代節點,這些節點構成一個 workInProgress 樹。後續所有發生 work 的地方都是在 workInProgress 樹中執行,如果該樹還未建立,則會建立一個 current 樹的副本,作為 workInProgress 樹。當 workInProgress 樹被提交後將會在 commit 階段的某一子階段被替換成為 current 樹。

這裡增加兩個樹的主要原因是為了避免更新的丟失。比如,如果我們只增加更新到 workInProgress 樹,當 workInProgress 樹通過從 current 樹中克隆而重新開始時,一些更新可能會丟失。同樣的,如果我們只增加更新到 current 樹,當 workInProgress 樹被提交後會被替換為 current 樹,更新也會被丟失。通過在兩個佇列都保持更新,可以確保更新始終是下一個 workInProgress 樹的一部分。並且,因為 workInProgress 樹被提交成為 current 樹,並不會出現相同的更新而被重複應用兩次的情況。

Effects list

effect list 可以理解為是一個儲存 effectTag 副作用列表容器。它是由 fiber 節點和指標 nextEffect 構成的單連結串列結構,這其中還包括第一個節點 firstEffect,和最後一個節點 lastEffect。如下圖所示:

image.png

React 採用深度優先搜尋演算法,在 render 階段遍歷 fiber 樹時,把每一個有副作用的 fiber 篩選出來,最後構建生成一個只帶副作用的 effect list 連結串列。
在 commit 階段,React 拿到 effect list 資料後,通過遍歷 effect list,並根據每一個 effect 節點的 effectTag 型別,從而對相應的 DOM 樹執行更改。

更多 effect list 構建演示流程,可以點選檢視動畫 《Effect List —— 又一個 Fiber 連結串列的構建過程》

Render 階段

在本文中,我們以類元件為例,假設已經開始呼叫了一個 setState 方法。

enqueueSetState

每個 React 元件都有一個相關聯的 updater,作為元件層和核心庫之間的橋樑。
react.Component 本質上就是一個函式,在它的原型物件上掛載了 setState 方法

檔案位置:react/src/ReactBaseClasses.js
// Component函式
function Component(props, context, updater) {
    this.props = props;
    this.context = context;
    this.updater = updater || ReactNoopUpdateQueue;
}

// Component原型物件掛載 setState
Component.prototype.setState = function (partialState, callback) {
    this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

React 給 work 大致分成以下幾種優先順序型別,其中 immediate 比較特殊,它的優先順序最高,可以理解為是同步排程,排程過程中不會被中斷。

export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;

React 有一套計算邏輯,根據不同的優先順序型別為不同的 work 計算出一個過期時間 expirationTime,其實就是一個時間戳。所謂的 React 在新的更新到來時,能為不同型別的更新分配優先順序順序的能力,本質上是根據過期時間 expirationTime 的大小來確定優先順序順序,expirationTime 數值越小,則優先順序越高。在相差一定時間範圍內的 work,React 會認為它們是同一個批次(batch)的,因此這一批次的 work 會在一次更新中完成。

檔案位置:react-reconciler/src/ReactFiberClassComponent.js
const classComponentUpdater = {
    enqueueSetState(inst, payload, callback) {
        // 獲取 fiber 物件
        const fiber = getInstance(inst);
        const currentTime = requestCurrentTime();

        // 計算到期時間 expirationTime
        const expirationTime = computeExpirationForFiber(currentTime, fiber, suspenseConfig);

        const update = createUpdate(expirationTime, suspenseConfig);
        // 插入 update 到佇列
        enqueueUpdate(fiber, update);
        // 排程 work 方法
        scheduleWork(fiber, expirationTime);
    },
};

renderRoot

檔案位置:react-reconciler/src/ReactFiberWorkLoop.js

協調過程總是 renderRoot 開始,方法呼叫棧:scheduleWork -->  scheduleCallbackForRoot  --> renderRoot

程式碼如下:

function renderRoot(
  root: FiberRoot,
  expirationTime: ExpirationTime,
  isSync: boolean,
) | null {
  do {
    // 優先順序最高,走同步分支
    if (isSync) {
      workLoopSync();
    } else {
      workLoop();
    }
  } while (true);
}

// 所有的fiber節點都在workLoop 中被處理
function workLoop() {
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

performUnitOfWork

所有的 fiber 節點都在 workLoop 方法處理。協調過程總是從最頂層的 hostRoot 節點開始進行 workInProgress 樹的遍歷。但是,React 會跳過已經處理過的 fiber 節點,直到找到還未完成工作的節點。例如,如果在元件樹的深處呼叫 setState,React 將從頂部開始,但會快速跳過父節點,直到到達呼叫了 setState 方法的元件。整個過程採用的是深度優先搜尋演算法,處理完當前 fiber 節點後,workInProgress 將包含對樹中下一個 fiber 節點的引用,如果下一個節點為 null 不存在,則認為執行結束退出 workLoop 迴圈並準備進行一次提交更改。

方法呼叫棧如下:
performUnitOfWork  -->  beginWork -->  updateClassComponent --> finishedComponent --> completeUnitOfWork

程式碼如下所示:

檔案位置:react-reconciler/src/ReactFiberWorkLoop.js
function performUnitOfWork(unitOfWork: Fiber): Fiber | null {
    const current = unitOfWork.alternate;

    let next;
    next = beginWork(current, unitOfWork, renderExpirationTime);

    // 如果沒有新的 work,則認為已完成當前工作
    if (next === null) {
        next = completeUnitOfWork(unitOfWork);
    }

    return next;
}

瞭解樹的深度優先搜尋演算法,可點選參考該示例 《js-ntqfill》

completeUnitOfWork

檔案位置:react-reconciler/src/completeUnitOfWork.js

在 completeUnitOfWork 方法中構建 effect-list 連結串列,該 effect list 在下一個 commit 階段非常重要,關於 effect list 上述有介紹。

如下所示:

function completeUnitOfWork(unitOfWork: Fiber): Fiber | null {
    // 深度優先搜尋演算法
    workInProgress = unitOfWork;
    do {
        const current = workInProgress.alternate;
        const returnFiber = workInProgress.return;

        /*
        構建 effect-list部分
    */
        if (returnFiber.firstEffect === null) {
            returnFiber.firstEffect = workInProgress.firstEffect;
        }
        if (workInProgress.lastEffect !== null) {
            if (returnFiber.lastEffect !== null) {
                returnFiber.lastEffect.nextEffect = workInProgress.firstEffect;
            }
            returnFiber.lastEffect = workInProgress.lastEffect;
        }

        if (returnFiber.lastEffect !== null) {
            returnFiber.lastEffect.nextEffect = workInProgress;
        } else {
            returnFiber.firstEffect = workInProgress;
        }
        returnFiber.lastEffect = workInProgress;

        const siblingFiber = workInProgress.sibling;
        if (siblingFiber !== null) {
            // If there is more work to do in this returnFiber, do that next.
            return siblingFiber;
        }
        // Otherwise, return to the parent
        workInProgress = returnFiber;
    } while (workInProgress !== null);
}

至此,一個 render 階段大概流程結束。

Commit 階段

commit 階段是 React 更新真實 DOM 並呼叫 pre-commit phase 和 commit phase 生命週期方法的地方。與 render 階段不同,commit 階段的執行始終是同步的,它將依賴上一個 render 階段構建的 effect list 連結串列來完成。

commitRootImpl

commit 階段實質上被分為如下三個子階段:

  • before mutation
  • mutation phase
  • layout phase

mutation 階段主要做的事情是遍歷 effect-list 列表,拿到每一個 effect 儲存的資訊,根據副作用型別 effectTag 執行相應的處理並提交更新到真正的 DOM。所有的 mutation effects 都會在 layout phase 階段之前被處理。當該階段執行結束時,workInProgress 樹會被替換成 current 樹。因此在 mutation phase 階段之前的子階段 before mutation,是呼叫 getSnapshotBeforeUpdate 生命週期的地方。在 before mutation 這個階段,真正的 DOM 還沒有被變更。最後一個子階段是 layout phase,在這個階段生命週期 componentDidMount/Update 被執行。

檔案位置:react-reconciler/src/ReactFiberWorkLoop.js

如下所示:

function commitRootImpl(root) {
    if (firstEffect !== null) {
        // before mutation 階段,遍歷 effect list
        do {
            try {
                commitBeforeMutationEffects();
            } catch (error) {
                nextEffect = nextEffect.nextEffect;
            }
        } while (nextEffect !== null);

        // the mutation phase 階段,遍歷 effect list
        nextEffect = firstEffect;
        do {
            try {
                commitMutationEffects();
            } catch (error) {
                nextEffect = nextEffect.nextEffect;
            }
        } while (nextEffect !== null);

        // 將 work-in-progress 樹替換為 current 樹
        root.current = finishedWork;

        // layout phase 階段,遍歷 effect list
        nextEffect = firstEffect;
        do {
            try {
                commitLayoutEffects(root, expirationTime);
            } catch (error) {
                captureCommitPhaseError(nextEffect, error);
                nextEffect = nextEffect.nextEffect;
            }
        } while (nextEffect !== null);

        nextEffect = null;
    } else {
        // No effects.
        root.current = finishedWork;
    }
}

commitBeforeMutationEffects

before mutation 呼叫鏈路:commitRootImpl -->  commitBeforeMutationEffects --> commitBeforeMutationLifeCycles

程式碼如下:

function commitBeforeMutationLifeCycles(
  current: Fiber | null,
  finishedWork: Fiber,
): void {
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent:
    ...
    // 屬性 stateNode 表示對應元件的例項
    // 在這裡 class 元件例項執行 instance.getSnapshotBeforeUpdate()
    case ClassComponent: {
      if (finishedWork.effectTag & Snapshot) {
        if (current !== null) {
          const prevProps = current.memoizedProps;
          const prevState = current.memoizedState;
          const instance = finishedWork.stateNode;
          const snapshot = instance.getSnapshotBeforeUpdate(
            finishedWork.elementType === finishedWork.type
              ? prevProps
              : resolveDefaultProps(finishedWork.type, prevProps),
            prevState,
          );

          instance.__reactInternalSnapshotBeforeUpdate = snapshot;
        }
      }
      return;
    }
    case HostRoot:
    case HostComponent:
    case HostText:
    case HostPortal:
    case IncompleteClassComponent:
      ...
  }
}

commitMutationEffects

檔案位置:react-reconciler/src/ReactFiberWorkLoop.js

mutation phase 階段呼叫鏈路:
commitRootImpl -->  commitMutationEffects --> commitWork

程式碼如下:

function commitMutationEffects() {
  while (nextEffect !== null) {
    const effectTag = nextEffect.effectTag;

    let primaryEffectTag = effectTag & (Placement | Update | Deletion);
    switch (primaryEffectTag) {
      case Placement:
        ...
      case PlacementAndUpdate:
        ...
      case Update: {
        const current = nextEffect.alternate;
        commitWork(current, nextEffect);
        break;
      }
      case Deletion: {
        commitDeletion(nextEffect);
        break;
      }
    }
  }
}

commitLayoutEffects

檔案位置:react-reconciler/src/ReactFiberCommitWork.js

layout phase 呼叫鏈路:commitRootImpl -->  commitLayoutEffects --> commitLifeCycles

程式碼如下:

function commitLifeCycles(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedExpirationTime: ExpirationTime,
): void {
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent:
      ...
    case ClassComponent: {
      // 屬性 stateNode 表示對應元件的例項
      // 在這裡 class 元件例項執行 componentDidMount/DidUpdate
      const instance = finishedWork.stateNode;
      if (finishedWork.effectTag & Update) {
        // 首次渲染時,還沒有 current 樹
        if (current === null) {
          instance.componentDidMount();
        } else {
          const prevProps =
            finishedWork.elementType === finishedWork.type
              ? current.memoizedProps
              : resolveDefaultProps(finishedWork.type, current.memoizedProps);
          const prevState = current.memoizedState;
          instance.componentDidUpdate(
            prevProps,
            prevState,
            instance.__reactInternalSnapshotBeforeUpdate,
          );
        }
      }
      const updateQueue = finishedWork.updateQueue;
      if (updateQueue !== null) {
        commitUpdateQueue(
          finishedWork,
          updateQueue,
          instance,
          committedExpirationTime,
        );
      }
      return;
    }
    case HostRoot:
    case HostComponent:
    case HostText:
    case HostPortal:
    case Profiler:
    case SuspenseComponent:
    case SuspenseListComponent:
      ...
  }
}

擴充套件

以下是一些關於 Fiber 的擴充套件內容。

呼叫鏈路

如下圖所示,根據 React 原始碼繪製的呼叫鏈路圖,主要羅列了一些比較重要的函式方法,可作為大家瞭解 Fiber 的參考。原始碼除錯過程可以找到對應的函式方法打斷點,以瞭解實際執行的過程,便於更好梳理出各個邏輯方法之間的關係。

fiber呼叫鏈路.jpg

requestIdleCallback

之前有文章在總結 React Fiber 的排程原理時提到,客戶端執行緒執行任務時會以幀的形式劃分,在兩個執行幀之間,主執行緒通常會有一小段空閒時間,在這個空閒期觸發 requestIdleCallback 方法,能夠執行一些優先順序較低的 work。

據說在早期的 React 版本上確實是這麼做的,但使用 requestIdleCallback 實際上有一些限制,執行頻次不足,以致於無法實現流暢的 UI 渲染,擴充套件性差。因此 React 團隊放棄了 requestIdleCallback 用法,實現了自定義的版本。比如,在釋出 v16.10 版本中,推出實驗性的 Scheduler,嘗試使用 postMessage 來代替 requestAnimationFrame。更多瞭解可以檢視 React 原始碼 packages/scheduler 部分。

小結

Fiber 由來已久,可以說是 React 設計思想的一個典型表現。相比業界其他流行庫更多采用當新資料到達時再計算模式,React 堅持拉取模式,即能夠把計算資源延遲到必要時候再用,並且它知道,什麼時候更適合執行,什麼時候不執行。看起來雖然只是微小的區別,卻意義很大。隨著後續非同步渲染能力等新特性的推出,我們有理由相信,在未來,React 將會在人機互動的應用中給我們帶來更多的驚喜。

參考

本文釋出自 網易雲音樂大前端團隊,文章未經授權禁止任何形式的轉載。我們常年招收前端、iOS、Android,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章