[Part3]JavaScript生態加速攻略:eslint

前端小智發表於2023-05-17
本文首發於微信公眾號:大遷世界, 我的微信:qq449245884,我會第一時間和你分享前端行業趨勢,學習途徑等等。
更多開源作品請看 GitHub https://github.com/qq449245884/xiaozhi ,包含一線大廠面試完整考點、資料以及我的系列文章。

快來免費體驗ChatGpt plus版本的,我們出的錢
體驗地址:https://chat.waixingyun.cn
可以加入網站底部技術群,一起找bug.

本文討論瞭如何透過最佳化選擇器引擎和AST轉換過程,以及完善JavaScript中的linter,從而加速JavaScript和TypeScript專案。作者提到,一個理想的用JS編寫的linter可以在不到一秒鐘的時間內執行完畢。

在本系列的前兩篇文章中,我們已經討論了很多關於程式碼風格檢查的內容,所以我認為是時候給eslint一個應有的關注了。總的來說,eslint非常靈活,甚至可以將解析器完全替換成另一個不同的解析器。隨著JSX和TypeScript的興起,這種情況並不少見。得益於豐富的外掛和預設生態系統,可能已經有了適用於每個使用場景的規則,如果還沒有,優秀的文件會指導你如何建立自己的規則。

但這也給效能分析帶來了問題,由於配置靈活性的廣泛性,兩個專案在進行程式碼檢查時可能會有非常不同的體驗。不過我們需要從某個地方開始,所以我想,何不從檢視 eslint 儲存庫中使用的程式碼檢查設定開始我們的調查呢!

使用 eslint 對 eslint 進行程式碼檢查

程式碼庫使用任務執行器抽象來協調常見的構建任務,但是透過一些挖掘,我們可以拼湊出針對 JavaScript 檔案進行“lint”任務的命令

node bin/eslint.js --report-unused-disable-directives . --ignore-pattern "docs/**"

Eslint正在使用eslint來檢查他們的程式碼庫!就像本系列的前兩篇文章一樣,我們將透過node的內建 --cpu-prof 引數生成 *.cpuprofile ,然後將其載入到Speedscope中進行進一步分析。幾秒鐘後(確切地說是22秒),我們準備好深入研究了!

image.png

透過合併類似的呼叫堆疊,我們可以更清楚地瞭解時間花費在哪裡。這通常被稱為“左重(left-heavy)”視覺化。這與標準的火焰圖不同,其中x軸表示呼叫發生的時間。相反,在這種風格中,x軸表示總時間消耗的時間,而不是發生的時間。對我來說,這是 Speedscope 的主要優點之一,而且感覺更加迅速。這並不意外,因為它是由 Figma 的幾個開發人員編寫的,他們以在我們行業中的工程卓越而聞名。

image.png

一個特定的 BackwardTokenCommentCursor 條目似乎很有趣,因為它是一堆中最大的塊。跟隨附加的檔案位置到原始碼,它似乎是一個儲存檔案中我們所處位置狀態的類。作為第一步,我新增了一個簡單的計數器,每當該類被例項化時就會增加,並再次執行了lint任務。

超過2000萬次後

總的來說,這個類已經被構建了超過2000萬次。這似乎相當多。請記住,我們例項化的任何物件或類都會佔用記憶體,這些記憶體稍後需要清理。我們可以在資料中看到這種後果,即垃圾回收(清理記憶體的行為)總共需要2.43秒。這不好。

在建立該類的新例項時,它呼叫了兩個函式,這兩個函式似乎都會啟動搜尋。不過,如果不瞭解它正在做什麼,第一個函式可以被排除在外,因為它不包含任何形式的迴圈。從經驗來看,迴圈通常是效能調查的主要嫌疑物件,因此我通常從那裡開始搜尋。

儘管第二個函式稱為 utils.search() ,但它包含一個迴圈。它迴圈遍歷從我們在此時進行程式碼檢查的檔案內容中解析出的標記流。標記是程式語言的最小構建塊,可以將它們視為語言的“單詞”。例如,在JavaScript中,function一詞通常表示為一個函式標記,逗號或單個分號也是如此。在這個 utils.search() 函式中,我們似乎關心找到檔案中最接近當前位置的標記。

exports.search = function search(tokens, location) {
    const index = tokens.findIndex(el => location <= getStartLocation(el));
    return index === -1 ? tokens.length : index;
};

為了做到這一點,透過JavaScript的本地 .findIndex() 方法在令牌陣列上進行搜尋。該演算法的描述如下:

findIndex() 是一種迭代方法。它按升序順序為陣列中的每個元素呼叫提供的 callbackFn 函式,直到 callbackFn 返回一個真值。

考慮到令牌陣列隨檔案中程式碼量的增加而增加,這並不理想。我們可以使用更有效的演算法來搜尋陣列中的值,而不是遍歷陣列中的每個元素。例如,將該行替換為二分搜尋可以將時間減半。

雖然減少50%聽起來不錯,但仍然沒有解決這個程式碼被呼叫2000萬次的問題。對我來說,這才是真正的問題。我們更多地是試圖減少這裡的症狀影響,而不是解決潛在的問題。我們已經在檔案中進行了迭代,因此我們應該知道自己在哪裡。不過,更改這一點需要進行更深入的重構,這對於本部落格文章來說太多了。鑑於這不是一個容易的修復,我檢查了一下在效能分析中還有哪些值得關注的地方。中心的長紫色條很難忽視,不僅因為它們是不同的顏色,而且因為它們佔用了很多時間,並且沒有深入到數百個較小的函式呼叫中。

選擇器引擎

在 speedscope 中,呼叫堆疊指向一個名為 esquery 的專案,我在此之前從未聽說過。這是一箇舊專案,其目標是透過一種小型選擇器語言在解析的程式碼中查詢特定物件。如果你眯起眼睛看,你會發現它與 CSS 選擇器有很強的相似之處。它們在這裡的工作方式相同,只是我們不是在 DOM 樹中查詢特定的 HTML 元素,而是在另一個樹結構中查詢物件。這是相同的想法。

image.png

這些痕跡表明,npm包附帶了壓縮後的原始碼。混淆的變數名通常只有一個字元,這強烈暗示了這樣一個過程。幸運的是,這個包還附帶了一個未壓縮的版本,所以我只是修改了package.json,讓它指向了那個版本。再次執行後,我們收到了以下資料:

image.png

未壓縮的程式碼的效能比壓縮的程式碼慢大約10-20%。

儘管如此,相對時間保持不變,因此它仍然非常適合我們的調查。因此, getPath 函式似乎需要一些幫助:

function getPath(obj, key) {
    var key  s = key.split('.');

    var _iterator = _createForOfIteratorHelper(keys),
        _step;

    try {
          for (_iterator.s(); !(_step = _iterator.n()).done;) {
                var _key = _step.value;

        if (obj == null) {
          return obj;
        }

                obj = obj[_key];
          }
    } catch (err) {
          _iterator.e(err);
    } finally {
          _iterator.f();
    }

    return obj;
}

過時的轉譯將長期困擾我們

如果你對JavaScript工具領域有所瞭解,那麼這些功能看起來會讓人覺得非常熟悉。_createForOfIteratorHelper幾乎可以肯定是由他們的釋出流程插入的函式,而不是這個庫的作者新增的。當for-of迴圈被新增到JavaScript時,它花費了一段時間才在各個地方得到支援。

將現代JavaScript功能降級的工具往往在謹慎性方面出錯,並以非常保守的方式重寫程式碼。在這個例子中,我們知道我們將一個字串拆分成一個字串陣列。用一個完全成熟的迭代器來迴圈遍歷這個陣列完全是過度設計,一個簡單的標準for迴圈就足夠了。但由於工具沒有意識到這一點,它們選擇了能覆蓋儘可能多場景的變體。這裡是原始程式碼供您參考:

function getPath(obj, key) {
    const keys = key.split(".");
    for (const key of keys) {
        if (obj == null) {
            return obj;
        }
        obj = obj[key];
    }
    return obj;
}

在現今的世界中,for-of迴圈已在各處得到支援,因此我再次修改了包,並將函式實現替換為原始碼中的原始版本。這個簡單的更改節省了大約400毫秒的時間。浪費在polyfills或過時降級處理上的CPU時間總是讓人印象深刻。你可能認為這種差異不會那麼大,但當你遇到像這樣的情況時,數字卻描繪出了一個不同的畫面。另外,我還嘗試用標準的for迴圈替換for-of迴圈進行了測量。

function getPath(obj, key) {
    const keys = key.split(".");
    for (let i = 0; i < keys.length; i++) {
        const key = keys[i];
        if (obj == null) {
            return obj;
        }
        obj = obj[key];
    }
    return obj;
}

這比for-of變體又提高了200毫秒。我想,即使在今天,for-of迴圈對引擎來說也更難進行最佳化。這讓我想起了過去Jovi和我調查graphql包解析速度突然降低的情況,當時他們在新版本中將迴圈方式切換為for-of迴圈。

這是一件只有 V8/Gecko/Webkit 工程師才能夠正確驗證的事情,但我的假設是它仍然必須呼叫迭代器協議,因為它可能已經被全域性覆蓋,這將改變每個陣列的行為。它可能是這樣的事情。

儘管我們透過這些改變取得了一些快速的勝利,但仍然遠非理想。總的來說,該功能仍然是一個待改進的熱門競爭者,因為它單獨負責總時間的幾秒鐘。再次應用快速計數器技巧揭示了它被呼叫了大約22k次。可以肯定的是,這是一個在"熱"路徑中的功能。

在許多效能密集型處理字串的程式碼中,特別需要注意的是 String.prototype.split() 方法。這將有效地迭代所有字元,分配一個新陣列,然後迭代該陣列,所有這些都可以在單個迭代中完成。

function getPath(obj, key) {
    let last = 0;
    // Fine because all keys are ASCII and not unicode
    for (let i = 0; i < key.length; i++) {
        if (obj == null) {
            return obj;
        }

        if (key[i] === ".") {
            obj = obj[key.slice(last, i)];
            last = i + 1;
        } else if (i === key.length - 1) {
            obj = obj[key.slice(last)];
        }
    }

    return obj;
}

這次重寫對其效能產生了很大的影響。當我們開始時, getPath 總共需要2.7秒,而在應用了所有最佳化後,我們設法將其降至486毫秒。

image.png

繼續使用 matches() 函式,我們看到由奇怪的 for-of 下傳遞建立的大量開銷,類似於我們之前看到的情況。為了節省時間,我直接在 Github 上覆制了原始碼中的函式。由於 matches() 在跟蹤中更加突出,僅這個更改就可以節省整整 1 秒鐘。

我們的生態系統中有很多庫都存在這個問題。我真的希望有一種方法可以透過一次點選更新它們所有。也許我們需要一個反向轉譯器,它可以檢測到向下轉譯的模式並將其轉換回現代程式碼。

我聯絡了 jviide,看看我們是否可以進一步最佳化 matches() 。透過他的額外更改,我們能夠使整個選擇器程式碼相對於原始未修改狀態快約5倍。他基本上是透過消除 matches() 函式中的一堆開銷來實現的,這使他也能夠簡化幾個相關的輔助函式。例如,他注意到模板字串的轉譯效果不佳。

// input
const literal = `${selector.value.value}`;

// output: down transpiled, slow
const literal = "".concat(selector.value.value);

他甚至更進一步,透過將每個新選擇器解析為一系列函式呼叫鏈,並在執行時快取生成的包裝函式。這個技巧為選擇器引擎帶來了另一個巨大的加速。我強烈建議檢視他的更改。我們還沒有發起PR,因為 esquery 似乎在這一點上沒有維護。

提前退出

有時候退一步並從不同的角度解決問題是很好的。到目前為止,我們看了實現細節,但我們實際上正在處理什麼樣的選擇器?有沒有潛力縮短其中的一些?為了測試這個理論,我首先需要更好地瞭解正在處理的選擇器的型別。毫不奇怪,大多數選擇器都很短。但其中有幾個選擇器是相當複雜的。例如,這裡有一個單獨的選擇器:

VariableDeclaration:not(ExportNamedDeclaration > .declaration) > VariableDeclarator.declarations:matches(
  [init.type="ArrayExpression"],
  :matches(
    [init.type="CallExpression"],
[init.type="NewExpression"]
  )[init.optional!=true][init.callee.type="Identifier"][init.callee.name="Array"],
[init.type="CallExpression"][init.optional!=true][init.callee.type="MemberExpression"][init.callee.computed!=true][init.callee.property.type="Identifier"][init.callee.optional!=true]
    :matches(
      [init.callee.property.name="from"],
      [init.callee.property.name="of"]
)[init.callee.object.type="Identifier"][init.callee.object.name="Array"],
[init.type="CallExpression"][init.optional!=true][init.callee.type="MemberExpression"][init.callee.computed!=true][init.callee.property.type="Identifier"][init.callee.optional!=true]:matches(
      [init.callee.property.name="concat"],
      [init.callee.property.name="copyWithin"],
      [init.callee.property.name="fill"],
      [init.callee.property.name="filter"],
      [init.callee.property.name="flat"],
      [init.callee.property.name="flatMap"],
      [init.callee.property.name="map"],
      [init.callee.property.name="reverse"],
      [init.callee.property.name="slice"],
      [init.callee.property.name="sort"],
      [init.callee.property.name="splice"]
    )
  ) > Identifier.id

當使用自定義特定領域語言時,可能會出現一些問題,例如匹配錯誤,而且通常沒有工具支援。相反,如果使用JavaScript,可以隨時使用適當的偵錯程式檢查值。雖然前面的字串選擇器示例有些極端,但大多數選擇器看起來都像這樣

BinaryExpression

或:

VariableDeclaration

就是這樣。大多數選擇器只想知道當前的AST節點是否是某種型別。僅此而已。為此,我們不真正需要整個選擇器引擎。如果我們為此引入了一條快速路徑並完全繞過選擇器引擎,那會怎樣呢?

class NodeEventGenerator {
    // ...

    isType = new Set([
        "IfStatement",
        "BinaryExpression",
        // ...etc
    ]);

    applySelector(node, selector) {
        // Fast path, just assert on type
        if (this.isType.has(selector.rawSelector)) {
            if (node.type === selector.rawSelector) {
                this.emitter.emit(selector.rawSelector, node);
            }

            return;
        }

        // Fallback to full selector engine matching
        if (
            esquery.matches(
                node,
                selector.parsedSelector,
                this.currentAncestry,
                this.esqueryOptions
            )
        ) {
            this.emitter.emit(selector.rawSelector, node);
        }
    }
}

重新思考選擇器

一種選擇器引擎在需要在不同語言之間傳遞遍歷命令時非常有用,比如我們在瀏覽器中使用CSS的情況。但是,選擇器引擎並不是免費的,因為它總是需要解析選擇器以拆解我們應該執行的操作,然後即時構建一些邏輯來執行那個解析後的內容。

但是在 eslint 中,我們沒有跨越任何語言障礙。我們仍然停留在 JavaScript 領域。因此,透過將查詢指令轉換為選擇器並將其解析回我們可以再次執行的內容,我們在效能方面沒有任何收益。相反,我們消耗了約 25% 的總體 linting 時間來解析和執行選擇器。需要一種新的方法。

然後我恍然大悟。

選擇器在概念上僅僅是一種“描述”,用於根據其所持有的條件查詢元素。這可以是在樹中進行查詢,也可以是在類似陣列的平面資料結構中進行查詢。如果你思考一下,即使是標準 Array.prototype.filter() 呼叫中的回撥函式也是一個選擇器。我們從一組專案(=陣列)中選擇值,並僅挑選我們關心的值。我們使用 esquery 所做的正是同樣的事情。從一堆物件(=AST節點)中,我們挑選出符合某種條件的物件。那就是選擇器!那麼,如果我們避免使用選擇器解析邏輯,並改用純 JavaScript 函式呢?

// String based esquery selector
const esquerySelector = `[type="CallExpression"][callee.type="MemberExpression"][callee.computed!=true][callee.property.type="Identifier"]:matches([callee.property.name="substr"], [callee.property.name="substring"])`;

// The same selector as a plain JS function
function jsSelector(node) {
    return (
        node.type === "CallExpression" &&
        node.callee.type === "MemberExpression" &&
        !node.callee.computed &&
        node.callee.property.type === "Identifier" &&
        (node.callee.property.name === "substr" ||
            node.callee.property.name === "substring")
    );
}

讓我們試試這個!我寫了一些基準測試來測量這兩種方法的時間差異。稍後,資料就會在我的螢幕上彈出。

image.png

看起來純 JavaScript 函式版本在效能方面輕鬆地超越了基於字串的版本。它的優越性非常明顯。即使在花費大量時間提高 esquery 的速度之後,它仍然無法接近 JavaScript 變體。在選擇器不匹配且引擎可以提前退出的情況下,它仍然比普通函式慢 30 倍。這個小實驗證實了我的假設,即我們為選擇器引擎付出了相當多的時間。

第三方外掛和預設的影響

儘管從eslint的設定中可以看到更多的最佳化空間,但我開始想知道我是否花時間最佳化了正確的東西。eslint自己的linting設定中看到的相同問題是否也會在其他linting設定中出現? eslint的關鍵優勢之一一直是其靈活性和對第三方linting規則的支援。回想一下,我所工作的每個專案幾乎都有幾個自定義linting規則和大約2-5個額外的eslint外掛或預設。但更重要的是,它們完全切換了解析器。快速檢視npm下載統計資料突顯了替換eslint內建解析器的趨勢。

image.png

如果這些數字是可信的,那麼這意味著只有8%的 eslint 使用者使用內建解析器。它還顯示了TypeScript已經變得非常普遍,佔據了eslint總使用者數的73%。我們沒有關於使用babel解析器的使用者是否也用於TypeScript的資料。我猜其中一部分人會這樣做, TypeScript使用者的總數實際上可能更高。

在各種開原始碼庫中對幾個不同的設定進行了分析後,我選擇了來自 vite 的設定,其中包含了其他配置檔案中存在的許多模式。它的程式碼庫是用 TypeScript 編寫的, eslint 的解析器也相應地被替換了。

image.png

與之前類似,我們可以在效能剖析圖中看到各個區域顯示出耗時的情況。有一個區域暗示了將TypeScript的格式轉換為eslint所理解的格式需要消耗相當多的時間。配置載入方面也出現了一些奇怪的情況,因為它實際上不應該佔用這麼多時間。我們還發現了一個老朋友,即eslint-import-plugineslint-plugin-node,它們似乎引發了一系列模組解析邏輯。

這裡有趣的一點是選擇器引擎的開銷並沒有顯示出來。有一些 applySelector 函式被呼叫,但在更大的畫面中它幾乎不消耗任何時間。

總是出現並需要相當長時間才能執行的兩個第三方外掛是 eslint-plugin-importeslint-plugin-node 。每當這兩個外掛中的一個或兩個處於活動狀態時,它們在分析資料中真正顯現。它們都會導致大量的檔案系統流量,因為它們試圖解析一堆模組,但不快取結果。我們在本系列的第二部分中寫了很多關於這個的內容,所以我不會再詳細介紹了。

轉換所有的AST節點

我們將從一開始的TypeScript轉換開始。我們的工具將我們提供給它們的程式碼解析為一種稱為抽象語法樹(簡稱:AST)的資料結構。你可以將其視為我們所有工具使用的基本構建塊。它提供了諸如:"嘿,這裡我們宣告瞭一個變數,它具有這個名稱和那個值",或者"這是一個帶有這個條件的if語句,它保護了那個程式碼塊"等信

// `const foo = 42` in AST form is something like:
{
  type: "VariableDeclaration",
  kind: "const",
  declarations: [
    {
      kind: "VariableDeclarator",
      name: {
        type: "Identifier",
        name: "foo",
      },
      init: {
        type: "NumericLiteral",
        value: 42
      }
  ]
}

可以在優秀的AST Explorer頁面上親自檢視我們的工具如何解析程式碼。我強烈建議訪問該網站並嘗試使用各種程式碼片段進行操作。這將幫助你更好地瞭解我們工具的AST格式有多相似或者多不同。

然而,在 eslint 的情況下存在一個問題。我們希望規則能夠在我們選擇的所有解析器中都能夠工作。當我們啟用 no-console 規則時,我們希望它能夠在所有解析器中都能夠工作,而不是強制每個規則都必須為每個解析器重新編寫。基本上,我們需要一個共享的 AST 格式,我們都可以同意。這正是 eslint 所做的。它期望每個 AST 節點都與 estree 規範匹配,該規範規定了每個 AST 節點應該如何檢視。這是一個已經存在了相當長時間的規範,許多 JavaScript 工具都是從這個規範開始的。甚至 babel 也是基於此構建的,但自那時以來有一些已記錄的偏差。

但這就是在使用TypeScript時問題的關鍵所在。TypeScript的AST格式非常不同,因為它還需要考慮表示型別本身的節點。某些構造在內部的表示方式也不同,因為這使得TypeScript本身更容易處理。這意味著每個TypeScript AST節點都必須轉換為 eslint 所理解的格式。這種轉換需要時間。在此配置檔案中,這佔總時間的約22%。它需要這麼長時間的原因不僅僅是遍歷,而且每次轉換時我們都會分配新物件。我們在記憶體中基本上有兩個不同AST格式的副本。

也許Babel的解析器更快?如果我們用 @babel/eslint-parser 替換 @typescript-eslint/parser 會怎樣?

image.png

原來這樣做可以節省我們相當多的時間。有趣的是,這個改變也大大縮短了配置載入時間。配置載入時間的改善可能是由於 Babel 的解析器分佈在較少的檔案中。

image.png

請注意,儘管在撰寫本文時,Babel解析器明顯更快,但它不支援型別感知的程式碼檢查。這是 @typescript-eslint/parser 獨有的功能。這為像 no-for-in-array 這樣的規則開啟了可能性,它可以檢測您在 for-in 迴圈中迭代的變數實際上是 object 而不是 array 。因此,您可能希望繼續使用 @typescript-eslint/parser 。但是,如果你確信自己沒有使用它們的任何規則,並且只是想要理解TypeScript的語法並更快地進行程式碼檢查,那麼切換到Babel的解析器是一個不錯的選擇。

還有一些關於 Rust 埠的閒聊,這引起了我的好奇心,想知道目前基於 Rust 的 JavaScript 語言檢查器有多快。唯一一個似乎有些生產就緒並能夠解析 TypeScript 語法大部分內容的是 rslint。

還有一些關於 Rust 埠的閒聊,這引起了我的好奇心,想知道目前基於 Rust 的 JavaScript 語言檢查器有多快。唯一一個似乎有些生產就緒並能夠解析 TypeScript 語法大部分內容的是 rslint。

除了 rslint ,我還開始想知道一個純 JavaScript 的簡單 linter 會是什麼樣子。它不需要選擇器引擎,不需要不斷進行 AST 轉換,只需要解析程式碼並檢查各種規則。所以我用一個非常簡單的 API 包裝了 babel 的解析器,並新增了自定義遍歷邏輯來遍歷 AST 樹。我沒有選擇 babel 自己的遍歷函式,因為它們在每次迭代時會導致大量的分配,並且是基於生成器構建的,這比不使用生成器要慢一些。我還嘗試了一些我自己多年來編寫的自定義 JavaScript/TypeScript 解析器,這些解析器最初是從幾年前將 esbuild 的解析器移植到 JavaScript 開始的。

話雖如此,在vite的程式碼庫(144個檔案)上執行所有這些數字的結果如下。

image.png

根據這些數字,我相當有信心,僅透過這個小實驗,我們就可以用 JavaScript 實現非常接近 Rust 的效能。

總結

總的來說, eslint 專案前景非常光明。它是最成功的開源專案之一,已經找到了獲得大量資金的秘訣。我們研究了一些可以使 eslint 更快的事情,還有很多其他方面的內容沒有涉及到。
“eslint的未來”討論包含了許多偉大的想法,這些想法可以使 eslint 變得更好,潛在地更快。我認為棘手的問題是避免一次性嘗試解決所有問題,因為在我的經驗中,這通常註定會失敗。同樣適用於從頭開始重寫。相反,我認為當前的程式碼庫是一個完美的起點,可以塑造成為更棒的東西。

從外部人的角度來看,有一些關鍵決策需要做出。比如,現在是否有意義繼續支援基於字串的選擇器?如果是,那麼 eslint 團隊是否有能力承擔 esquery 的維護工作並給予它所需的關注?還有,考慮到 npm 下載量表明 73% 的 eslint 使用者是 TypeScript 使用者,那麼原生 TypeScript 支援又該怎麼辦呢?

程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug

交流

有夢想,有乾貨,微信搜尋 【大遷世界】 關注這個在凌晨還在刷碗的刷碗智。

本文 GitHub https://github.com/qq449245884/xiaozhi 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

相關文章