Mark Ku's Blog
Podcast 對話本文 AI 對話朗讀版

📖 功能介紹頁 | ⬇️ 從 VS Code Marketplace 安裝

免費使用,VS Code 內按 Ctrl+P 貼上 ext install mark-ku.refactory 也可直接安裝。

前言

把主力開發搬進 VS Code 之後,最不習慣的是重構(不改變程式行為,只把它整理得更好讀)。

在傳統商業 IDE 裡按一個鍵,這個位置能做的事會全部列出來。到了 VS Code,這些功能散在各處,而且少了一大半。就算有,產生出來的碼也不像你專案的碼:介面該放哪個資料夾?DI 註冊寫在哪?'use client' 該不該帶過去?通用工具都不知道。

所以我做了 Refactory,已上架 Marketplace,免費使用,34 個重構動作 + 38 條壞味道檢查、18 種語言

這篇只講怎麼操作:游標放哪、按什麼鍵、按完變成什麼樣子。

一、安裝(30 秒)

VS Code 內按 Ctrl+P 貼上 ext install mark-ku.refactory,或在 Extensions 面板搜尋 Refactory。裝完就能用,不用設定

要用 C# 功能的話電腦要有 .NET 8 runtime;沒裝只會關掉 C# 那一半,React / TypeScript 不受影響。

二、只要記一組快速鍵

按鍵什麼時候按
Ctrl+Alt+Shift+T主力。把游標所在位置能做的事全部列出來
Ctrl+.原生燈泡選單,Refactory 的動作也嵌在裡面
Ctrl+Shift+P → 打 Refactory對「整個檔案」操作的指令
編輯器裡按右鍵選單裡也有一項「重構這裡…」,跟第一列同一個東西

記住第一個就夠了。

Refactor This 聚合選單,Refactory 與內建的重構並列在同一個選單
Refactor This 聚合選單,Refactory 與內建的重構並列在同一個選單

兩個會幫到你的地方:最近用過的動作會浮到最上面不能用的動作也會列出來並告訴你原因,不用猜。

習慣按 Ctrl+. 的人不用改習慣,動作一樣找得到:

原生 refactor 選單,Refactory 的動作正確落在 Extract、Rewrite、Move 分組內
原生 refactor 選單,Refactory 的動作正確落在 Extract、Rewrite、Move 分組內

三、React / Next.js

3-1 把一段 JSX 抽成元件

Extract Component 實機錄影:選取 JSX、抽出元件、原地命名
Extract Component 實機錄影:選取 JSX、抽出元件、原地命名

怎麼操作

  1. 選取一段完整的 JSX 區塊
  2. Ctrl+Alt+Shift+T
  3. Extract JSX into a component(同檔)或 …into a new component file(新檔)
  4. 打名字,Enter

選取這段它產生這樣(props 型別自動推導,不用自己填):

選取的JSX/抽出來的元件tsx
選取的JSX
1-<section className="hero">
2-  <h1>{active}</h1>
3-  <button onClick={() => setActive('next')}>Next</button>
4-</section>         
抽出來的元件
1+interface NewComponentProps {
2+  active: string
3+  setActive: (value: string) => void
4+}
5+
6+function NewComponent({ active, setActive }: NewComponentProps) {
7+  return (
8+    <section className="hero">
9+      <h1>{active}</h1>
10+      <button onClick={() => setActive('next')}>Next</button>
11+    </section>
12+  )
13+}

原本的位置自動換成 <NewComponent active={active} setActive={setActive} />

Extract Component 執行結果,四處元件名稱同時被框選
Extract Component 執行結果,四處元件名稱同時被框選

注意狀態列的 4 selections:呼叫點、interface 名稱、函式名稱、型別引用同時進入編輯狀態,打一次字四處一起改

抽到新檔案時會順手做這些:'use client' 帶過去、只搬用到的 import、共用常數改成 export + import(不是複製一份)、檔案位置與命名照你專案的慣例。

跨檔案操作會先跳 Refactor Preview 面板,逐條確認後才寫入。

3-2 把一坨 state 邏輯抽成自訂 hook

怎麼操作:選取連續的幾行(至少含一個 hook 呼叫)→ Ctrl+Alt+Shift+TExtract into a custom hook → 打名字

選取的幾行/抽出來的hooktsx
選取的幾行
1-const [query, setQuery] = useState('')
2-const [results, setResults] = useState<Post[]>([])
3-useEffect(() => {
4-  if (!query) return
5-  fetchPosts(query).then(setResults)
6-}, [query])    
抽出來的hook
1+function useSearch() {
2+  const [query, setQuery] = useState('')
3+  const [results, setResults] = useState<Post[]>([])
4+  useEffect(() => {
5+    if (!query) return
6+    fetchPosts(query).then(setResults)
7+  }, [query])
8+
9+  return { query, setQuery, results }
10+}

useEffect 的依賴陣列絕對不會被動到。搬完覺得依賴怪怪的,代表這段本來就有問題,那是另一個 commit 的事。

3-3 把 JSX 包起來:條件、.map()、fragment

三個都是選取一段完整 JSX 之後按 Ctrl+Alt+Shift+T,差別只在選單挑哪一項。

包成條件顯示

選取的 JSX/Wrap in a conditionaltsx
選取的 JSX
1 <div>
2-  <b>{n}</b>
  3 </div>
Wrap in a conditional
1 <div>
2+  {condition && (
3+    <b>{n}</b>
4+  )}
5 </div>

包成 .map()

選取的 JSX/Wrap in a .map()tsx
選取的 JSX
1 <ul>
2-  <li>{name}</li>
  3 </ul>
Wrap in a .map()
1 <ul>
2+  {items.map((item) => (
3+    <li key={item}>{name}</li>
4+  ))}
5 </ul>

conditionitemsitem 都是就地可編輯的游標點,Tab 跳下一個,打字直接改名,不用回頭找。

key={item} 是先幫你補上去、同時附一條警告:沒有人知道你的資料拿什麼當唯一鍵,所以它給一個編得過的預設值並明說「請換成穩定 id」,而不是安靜地留一個空位,讓 React 事後才在 console 抱怨。

兩個以上的兄弟節點時,選單改成提供 fragment(<>…</>);只選一個根節點時才會出現 .map()——包單一節點成 fragment 沒有意義,而 .map() 一次包兩個節點會產生無效的 JSX。

3-4 const X: FC<Props> 轉成函式宣告

怎麼操作:游標放在元件宣告那一行 → Ctrl+Alt+Shift+T → 選轉成函式宣告

箭頭函式 + FC/函式宣告tsx
箭頭函式 + FC
1 interface Props {
2   title: string
3 }
4 
5-const Card: FC<Props> = ({ title }) => {
6   return <b>{title}</b>
7-}
8-
9-export default Card
函式宣告
1 interface Props {
2   title: string
3 }
4 
5+export default function Card({ title }: Props) {
6   return <b>{title}</b>
7+}  

兩個細節:型別從變數搬到參數上;下面那行單獨的 export default Card 被吸收掉,不會留一行孤兒。簡寫的 body(=> <b>{title}</b>)會補成 return

這個動作一定會附一條警告FC 隱含一個 children prop,轉成函式宣告之後就沒有了。如果你的元件其實有人塞 children 進來,型別會從這一刻開始才真的檢查你。泛型元件(<T,>)則直接拒絕,因為 FC<Props> 本來就寫不出泛型。

3-5 改一個 prop 的名字,連所有呼叫點一起改

這是純手工最容易漏的一種:改了型別、改了解構,漏掉別的檔案裡的 <Card title="…" />

怎麼操作:游標放在 props 型別裡的那個屬性上 → Ctrl+Alt+Shift+T → 選改 prop 名稱 → 打新名字

Card.tsx(title)/Card.tsx(heading)tsx
Card.tsx(title)
1 interface CardProps {
2-  title: string
3   count: number
4 }
5 
6-export function Card({ title, count }: CardProps) {
7-  return <b>{title}: {count}</b>
8 }
Card.tsx(heading)
1 interface CardProps {
2+  heading: string
3   count: number
4 }
5 
6+export function Card({ heading, count }: CardProps) {
7+  return <b>{heading}: {count}</b>
8 }

同一次動作也會把別的檔案裡的呼叫點改掉:

Panel.tsx(改之前)/Panel.tsx(改之後)tsx
Panel.tsx(改之前)
1 export function Panel() {
2-  return <Card title="hello" count={1} />
3 }
Panel.tsx(改之後)
1 export function Panel() {
2+  return <Card heading="hello" count={1} />
3 }

旁邊的 count 一個字都沒動。解構時本來就寫了別名({ title: heading })的話,它只改型別成員,不會去動你已經取好的區域名稱。

遇到 <Card {...props} /> 這種展開的呼叫點會直接拒絕:那個物件裡到底有沒有 title,要靠型別推導才知道,一個 lex 層級的索引不該假裝自己知道。

3-6 Inline variable 與 import 路徑轉換

Inline variable(把只用一次的中間變數就地展開):

展開前/展開後tsx
展開前
1 export function Panel() {
2-  const label = 'hello'
3-  return <b>{label}</b>
4 }
展開後
1 export function Panel() {
2+  return <b>{'hello'}</b>
 3 }

用了兩次以上、而且初始化沒有副作用時會全部展開,並在需要的地方補上括號保住運算優先序(n * 2 展開成 {n * 2}{n * 2})。三種情況它會拒絕:let(值會變)、初始化有副作用又被用了不只一次(會多跑幾次),還有useEffect 依賴陣列點名的變數(展開等於偷偷改了依賴)。

import 路徑轉換(游標放在路徑字串上):

相對路徑/alias 路徑ts
相對路徑
1-import { posts } from '../../data/posts'
2-import type { TPost } from '../../data/types'
alias 路徑
1+import { posts } from '@/data/posts'
2+import type { TPost } from '@/data/types'

alias 是從管這個檔案的那份 tsconfig.jsonpaths 讀出來的,不用另外設定;import typeexport … from、動態 import()、純副作用 import 都認得,原本用單引號或雙引號也照舊。兩個方向可以來回轉,轉回去會拿到一模一樣的原文。

3-7 游標放哪:完整對照表

游標放錯位置是這類工具最常卡的地方,直接查這張表:

想做的事游標 / 選取位置
抽 JSX 成元件選取一段完整的 JSX 區塊
抽自訂 hook選取連續的幾行,至少含一個 hook 呼叫
const X: FC<Props> 轉函式宣告放在元件宣告上
包 fragment / 條件 / .map()選取一段完整的 JSX 區塊
Inline variable(變數就地展開)放在 const 宣告或它的任一引用上
單一 import 轉 alias / 相對路徑放在路徑字串上(如 '../../data/posts'
整檔 import 一次轉換Ctrl+Shift+PRefactory: TypeScript: Convert Imports…
把宣告搬到獨立檔案放在被宣告的那個名稱上(引用處會一起改)
Safe delete(安全刪除)放在被宣告的名稱上;有人在用會拒絕並列出誰在用
改 prop 名稱(含所有呼叫點)放在 props 型別裡的那個屬性上
'use client' 修正不用操作,會出現在 Problems 面板,Ctrl+. 套用

三個提醒:JavaScript 也適用(產出不帶型別註記,副檔名跟著來源走);import alias 不用設定,直接從管這個檔的 tsconfig.json 讀;整檔轉換刻意只放命令面板,避免游標一動就跳出來。

3-8 'use client' 自動提醒(Next.js)

不用操作,在 Next.js 專案裡打字就會出現:

情況它怎麼處理
用了 useState 卻忘了加 directive出現警告,Ctrl+.Add 'use client' directive
加了,但同檔又 export 只能跑在伺服器的東西不給快速修復,並直說:要把 client 的部分拆成獨立元件,不是把邊界往上搬
加了但根本用不到最低等級的提示,拿掉就不會被打包進瀏覽器

3-9 React 壞味道即時提醒

「壞味道」=編得過、但之後會讓你痛的寫法。打字時就畫波浪線,每條都附 Ctrl+. 快速修復。

React 壞味道即時偵測與一鍵修復的實機錄影
React 壞味道即時偵測與一鍵修復的實機錄影
  • 元件裡面又定義元件(每次 render 都變新型別,state 直接歸零,最嚴重的一條)
  • 直接改動 state:對 useState 的值做 items.push(...),畫面永遠不會更新
  • useEffect:沒有依賴陣列、callback 寫成 async、訂閱沒有 cleanup、effect 太肥
  • .map()index 當 key
  • hook 太多、props 太多、元件太大、JSX 巢狀太深、JSX 裡塞巢狀三元運算
  • Next.js 專案用原生 <img>(該用 next/image
  • 一般 TypeScript:明寫 any、空的 catchconsole.log、檔案過長

嚴重度刻意分級:會炸的是 warning、結構問題是 information、品味問題是最淡的 hint,真正重要的訊號才不會被淹掉。

3-10 一個 15 行的元件,五條壞味道

下面這個檔案是外掛自己的測試素材,也是第五節儀表板截圖裡那個 LiveTicker.tsx。它編得過、跑得起來、看起來人畜無害,五條 warning 全中:

LiveTicker.tsx(5 條 warning)/修好之後(0 條)tsx
LiveTicker.tsx(5 條 warning)
1 import { useEffect, useState } from 'react';
2 
    3 export function LiveTicker({ symbols }: { symbols: string[] }) {
4-  const [quotes, setQuotes] = useState<string[]>([]);
5 
6   useEffect(() => {
7-    setInterval(() => {
8-      quotes.push(symbols[0]);
9     }, 1000);
10-  });
11-
12-  const Row = ({ text }: { text: string }) => <li>{text}</li>;
13 
14-  return <ul>{quotes.map((q) => <Row text={q} />)}</ul>;
    15 }
修好之後(0 條)
1 import { useEffect, useState } from 'react';
2 
3+let nextId = 0;
4+
5+const Row = ({ text }: { text: string }) => <li>{text}</li>;
6+
7 export function LiveTicker({ symbols }: { symbols: string[] }) {
8+  const [quotes, setQuotes] = useState<{ id: number; text: string }[]>([]);
9 
10   useEffect(() => {
11+    const timer = setInterval(() => {
12+      setQuotes((prev) => [...prev, { id: nextId++, text: symbols[0] }]);
13     }, 1000);
14+    return () => clearInterval(timer);
15+  }, [symbols]);
 16 
17+  return (
18+    <ul>
19+      {quotes.map((q) => <Row key={q.id} text={q.text} />)}
20+    </ul>
21+  );
22 }

五條分別是:

規則 id它為什麼會咬你
useEffect(() => {…})react.effectWithoutDeps沒有依賴陣列,每次 render 都重跑一次
setInterval(…)react.effectMissingCleanup沒有 cleanup,重新掛載後舊的那顆還在跑(StrictMode 下直接兩顆)
quotes.push(…)react.mutatedState改的是同一個陣列,reference 沒變,React 看不到要重繪什麼
const Row = …react.componentInComponent每次 render 都是一個新的元件型別,React 會卸載它、丟掉它的 state 與 DOM
.map((q) => <Row …>)react.missingKey沒給 key,React 安靜地退回用 index

這五條都是 warning,因為每一條都會在 runtime 咬人,而不是「風格不夠好」。修法就是右邊那一欄:定時器拿變數接住並在 cleanup 清掉、push 換成產生新陣列的 setQuotesRow 搬到模組層、key 綁在資料本身的穩定 id 上。

四、C#

C# refactor 選單,七個 Refactory 動作
C# refactor 選單,七個 Refactory 動作
動作它做什麼
加注入依賴一次改好六個地方(下面詳講)
Extract Interface抽出介面,放到你專案實際擺介面的資料夾
介面成員同步介面加了新成員,一鍵在所有實作補上
DI 註冊把 service 註冊進 DI container
ConfigureAwait 補齊整檔補上 ConfigureAwait(false)
產生 XML 文件幫成員產生 XML 註解(介面有寫就沿用它的措辭)
控制器 clone 到下一版v1 controller 複製成 v2,該改的引用一起改

這七個只在按 Ctrl+Alt+Shift+T 時出現,不會主動跳燈泡(判斷游標在不在 class 裡要問 Roslyn,每次移動都問太慢)。

4-1 加注入依賴:一個動作改六個地方

依賴注入(DI)就是「不要自己 new,改成叫別人送進來」。手動加一個依賴要改六個地方,漏一個就編不過。

C# 加注入依賴實機錄影:六筆編輯一次落地
C# 加注入依賴實機錄影:六筆編輯一次落地

怎麼操作:游標放在 class 內任何位置Ctrl+Alt+Shift+TAdd injected dependency… → 輸入型別(例如 IPlanService)→ 輸入命名空間(可留空)

// 原本的 class
public class CouponService : ICouponService
{
    /// <summary>優惠券用戶端。</summary>
    private readonly ICouponClient couponClient;

    /// <param name="couponClient">優惠券用戶端。</param>
    public CouponService(ICouponClient couponClient)
    {
        this.couponClient = couponClient;
    }
}

按下去之後,這六個地方同時改好

  1. using 敘述(依字母序插入,不是接在最後面)
  2. 新的 private readonly 欄位
  3. 欄位的 XML 註解
  4. 建構子參數
  5. <param> 標籤(插在跟參數位置對應處,StyleCop 會檢查順序)
  6. 建構子裡的 this. 賦值

六筆編輯、一次 undo 全部復原,落地前先讓你看過:

Refactor Preview 面板逐條列出這次要落地的編輯,每一條都可以單獨取消
Refactor Preview 面板逐條列出這次要落地的編輯,每一條都可以單獨取消

面板上是五列不是六列——欄位和它上面那行 XML 註解是同一段插入,所以併在同一列顯示。由上到下對應的就是 using、欄位(含註解)、<param>、建構子參數、this. 賦值。

產出的風格是從你正在編輯的那個 class「量」出來的,不是讀設定檔。欄位有沒有底線前綴、賦值有沒有 this.、有沒有 XML 註解,照現有的樣子生。

兩種情況它會直接拒絕:這個 class 有手動 new Foo(...) 的呼叫點(硬塞 null! 編得過但 runtime 會炸);還有「DI 註冊了沒」它從不假裝檢查過,會明說沒驗證並強制走預覽。

4-2 Extract Interface:目標資料夾跟鄰居學

怎麼操作:游標放在 class 名稱上 → Ctrl+Alt+Shift+TExtract Interface

內建版本會把新檔寫在 class 旁邊,但很多分層專案的介面固定住在鏡像資料夾:

BLL/
├── Contracts/Services/Coupons/ICouponService.cs   ← 介面在這
└── Services/Coupons/CouponService.cs              ← 實作在這

Refactory 的做法是:看同資料夾其他已經有介面的 class,查它們的介面住在哪,取多數決。推導結果會寫在提示裡(例如「依 3 個兄弟類別推得」),學不到也直說。產出的介面檔會帶上你專案的 copyright header、XML 註解與 BOM。

4-3 整檔補上 ConfigureAwait(false)

函式庫程式碼漏掉 ConfigureAwait(false),在舊版同步環境裡是經典的 deadlock 來源。這個動作一次掃完整個檔案,只補漏掉的那幾個。

怎麼操作:游標放在 class 內任何位置 → Ctrl+Alt+Shift+TAdd missing ConfigureAwait(false) in this file

補之前/補之後csharp
補之前
1 public async Task RunAsync()
2 {
3     var a = await this.client.GetAsync().ConfigureAwait(false);
4-    var b = await this.client.PostAsync();
5-    await this.client.FlushAsync();
6 }
補之後
1 public async Task RunAsync()
2 {
3     var a = await this.client.GetAsync().ConfigureAwait(false);
4+    var b = await this.client.PostAsync().ConfigureAwait(false);
5+    await this.client.FlushAsync().ConfigureAwait(false);
6 }

第一行本來就有,所以不會被加第二次await Task.Yield() 會被跳過(它根本沒有 ConfigureAwait)。全部補完之後再按一次,它會直接說沒事可做,而不是產生一份空的編輯。

4-4 幫成員產生 XML 文件

怎麼操作:游標放在方法 / 屬性 / 建構子的宣告上 → Ctrl+Alt+Shift+TDocument this member

沒有註解/產生的骨架csharp
沒有註解
    1 public Task<int> GetAsync(string code, int page)
2 {
3     return Task.FromResult(0);
4 }
產生的骨架
1+/// <summary></summary>
2+/// <param name="code"></param>
3+/// <param name="page"></param>
4+/// <returns></returns>
5 public Task<int> GetAsync(string code, int page)
6 {
7     return Task.FromResult(0);
8 }

參數有幾個就有幾個 <param>,順序跟著簽章走;void 不會生 <returns>;建構子直接套 .refactory.json 裡設定的那句話(預設是 Initializes a new instance of the <see cref="Service"/> class.)。

這個成員實作了某個介面、而介面那邊已經寫過註解的話,它會把介面的措辭抄過來——連中文都照抄——並附一條提醒:StyleCop 的 SA1625 討厭一字不差的重複文件,你可能想改寫一下。已經有註解的成員它會拒絕,不會覆蓋你寫過的東西。

4-5 C# 壞味道檢查(開檔與存檔時跑)

每條一樣附 Ctrl+. 快速修復:

  • Sync-over-async:在 Task 上用 .Result / .Wait() / .GetAwaiter().GetResult(),經典 deadlock 來源
  • async 程式裡出現 Thread.Sleep、事件處理器以外的 async void
  • 建構子注入太多(超過 5 個)、空的 catch、catch 了沒 rethrow
  • 方法太長、class 太大、檔案太長、巢狀太深
  • magic number、巢狀三元運算、public 可變欄位
  • 一個檔案塞好幾個頂層型別、TODO / FIXME 標記

門檻是在真實專案上校準過的(85 檔的 Next.js 後台、1,312 檔的分層 C# solution)。OnModelCreatingConfigureServices 這種本來就長的方法已豁免長方法規則。

五、Code Health 儀表板

怎麼操作Ctrl+Shift+PRefactory: Code Health Dashboard,或點狀態列右下角的 $(pulse) 計數器

剛打開的 Code Health 儀表板:目前這個檔案的五條問題,依檔案分組、每條都寫清楚原因與規則 id
剛打開的 Code Health 儀表板:目前這個檔案的五條問題,依檔案分組、每條都寫清楚原因與規則 id

剛打開時看到的是「目前這個檔案」(上圖就是還沒掃描的狀態:四張數字卡、沒有 Copy report)。按右上角 Scan workspace 才會擴到整個工作區,也才會多出平均健康分數、Trends 與 Hotspots——下面 5-3 那張圖就是掃完之後的樣子。

裡面有三個名詞,白話講一次。

5-1 圈複雜度:這個函式有幾條路可以走

數字越大,你要看懂它、或要寫測試蓋滿它,要顧的分支越多。算法是從 1 開始,每遇到一個岔路就 +1if、迴圈、每個 casecatch、三元運算子、每個 && || ??):

function getDiscount(user, cart) {          // 起始 1
  if (!user) return 0                       // +1 → 2
  if (user.isVip && cart.total > 1000) {    // +1 (if) +1 (&&) → 4
    return 0.2
  }
  for (const item of cart.items) {          // +1 → 5
    if (item.onSale) return 0.1             // +1 → 6
  }
  return user.coupon ? 0.05 : 0             // +1 → 7
}

這個函式是 7,預設門檻 15,所以很健康。

補充:匿名 callback 和 lambda 算進最近的具名函式,所以 React 元件的複雜度會包含裡面的 inline handler 和 .map() body(讀這個元件時本來就要一起讀懂)。

5-2 健康分數:100 分起跳往下扣

扣分項目扣多少
每個 warning(會炸的問題)10 分
每個 suggestion(結構問題)3 分
每個 hint(品味問題)1 分
函式複雜度超過門檻的部分超出多少扣多少

假設某檔案有 2 warning、3 suggestion、1 hint,加一個複雜度 20 的函式(門檻 15,超出 5):

扣分 = 2×10 + 3×3 + 1×1 + 5 = 35
分數 = 100 − 35 = 65 → D

等第:90 以上 A、80–89 B、70–79 C、60–69 D、60 以下 F

分數不會擋 build 也不會跳警告,它只用來排序,回答「我今天該先看哪個檔案」。

5-3 Hotspots:常改 × 不健康 = 最該先修

一個很爛但半年沒人動的檔案,跟一個天天被改的檔案,優先順序完全不同。所以把兩件事相乘

熱點分數 = 最近常不常改 × 有多不健康(100 − 健康分數)

關鍵是相乘不是相加,任一邊是 0 結果就是 0:很爛但沒人動的、天天改但很乾淨的,都不會排前面;又常改又不健康的才會排到最上面

「常不常改」是跑一次 git log,數這個檔案最近 90 天被幾個 commit 動過。沒有 git 的話這區塊直接不出現,不會跳錯誤煩你。

Metrics、Trends 與 Hotspots:趨勢長條圖、熱點排名與最複雜的函式清單
Metrics、Trends 與 Hotspots:趨勢長條圖、熱點排名與最複雜的函式清單

5-4 面板怎麼用

掃過一次之後,由上到下是:五張數字卡(smells / warnings / suggestions / hints,加上掃描後才出現的平均健康分數)→ Trends(每掃一次記一根長條,最近 30 次,看得出這週比上週好還是壞)→ HotspotsMost complex functions(最複雜的 10 個,點了就跳過去)→ 檔案清單(等第徽章 + 逐條問題,點一下跳到那一行)。

按鈕做什麼
Scan workspace掃整個工作區。直接讀硬碟,不開檔也不灌爆 Problems 面板,可中途取消。TS/JS 與 C# 各自最多 2,000 檔
Copy report把發現轉成 Markdown 複製,直接貼進 PR 描述(掃過一次才會出現)
Refresh重新整理
右上角那排色點八種面板主題,第一個是跟著 VS Code 佈景走

5-5 一鍵清掉機械性的問題

怎麼操作Ctrl+Shift+PRefactory: Clean Up This File

  • TypeScript / JavaScript:刪掉所有「獨佔一行」的 console.log(...)
  • C#:把 catch 裡的 throw ex; 改成 throw;(保住 stack trace)

跑完會回報做了什麼(例如 3 console.logs removed, 1 rethrow fixed),一個 undo 全部復原

六、一鍵交給本機 Claude Code 修

本機有裝 Claude Code CLI 且在 PATH 上的話,會多出三個入口:

入口一:任一條壞味道的 Ctrl+. 選單多一項 Fix with Claude Code… 入口二:儀表板每個檔案右側的 ✦ Review(Hotspots 區塊還有 ✦ Review top 3入口三Ctrl+Shift+PRefactory: Deep Review This File with Claude (AI),不用先開儀表板

入口一會開整合終端機,跑你自己的 claude session,prompt 帶上規則 id、檔案、行號與訊息,並要求改動最小化。

入口二與入口三會先寫一份 briefing 給 Claude(行數、函式數、最高與平均複雜度、健康分數與等第、最複雜的幾個函式、最近哪幾個 commit 動過它、所有找到的問題),請它產出排好優先順序的重構建議。briefing 寫在 .refactory/reviews/,那個資料夾自己帶一個內容為 *.gitignore,不會被 commit。

你的登入、你的模型、你的核可流程,外掛只負責把 prompt 寫好。沒有任何東西會被自動套用,你看著 diff 落地。

七、另外 13 種語言:最常用的那幾個重構

Python、Go、Java、Kotlin、Rust、PHP、C、C++、Objective-C、Objective-C++、Dart、Swift、Scala 各有 11 個結構性重構,不用 language server、不用 SDK、什麼都不用裝。開一個 .go 檔,就算你連 Go 擴充都沒裝,這些照樣出得來。

動作在哪些語言
Invert if condition全部
Merge nested if · Split if condition全部
Add braces · Remove braces有大括號的那幾種
Introduce variable · Inline variable全部(TS / JS 除外,編輯器內建的更好用)
Apply De Morgan's law全部
Replace if with ?:除了 Go(它沒有三元運算子)
轉插值字串Python、C#、Kotlin、PHP、Dart、Swift
.format() / % 轉 f-stringPython

怎麼操作:游標放在 if 關鍵字或它的條件式上(不是 body 裡隨便哪裡)→ Ctrl+Alt+Shift+T

游標一定要在條件式上,否則四層巢狀的 if 會同時全部提供,你分不出哪個是哪個。

注意:除了 Introduce variable 和轉插值字串(這兩個讓給 VS Code 內建的型別感知版本),上面這些在 TypeScript / JavaScript 一樣用得到。下面的例子挑不同語言寫,只是為了展示同一個動作在各語言會產生該語言的寫法。

7-1 Invert if:把 happy path 拉回左邊

游標放在 if 上 → Invert if condition

反轉前/反轉後go
反轉前
1-if err == nil {
2-    process(data)
3-} else {
4     return err
  5 }
反轉後
1+if err != nil {
  2     return err
3+} else {
4+    process(data)
5 }

它做的是條件取反 + 兩個分支對調,不會自作主張幫你把 else 拆掉——那是另一個決定,不該混在同一個動作裡。條件本來就有 ! 的話會把它拿掉,而不是疊成 !!!(a && b) 這種會連括號一起收乾淨變成 a && b

一個別人不會提的小地方:if (a < b) 反轉成 if (a >= b)在浮點數上這兩者不等價(遇到 NaN 兩邊都是 false)。運算元看起來像浮點數時它會跳提醒並強制走預覽,市面上的工具多半直接翻給你。

7-2 Merge nested if:把金字塔壓平

游標放在外層if 上 → Merge nested if

兩層守衛/合併後ts
兩層守衛
1 function f(user) {
2-  if (user) {
3-    if (user.isActive) {
4-      send(user)
5-      log(user)
6-    }
7   }
8 }
合併後
1 function f(user) {
2+  if (user && user.isActive) {
3+    send(user)
4+    log(user)
  5   }
6 }

Python 也吃,而且會連縮排一起收回來(用的是 and,不是 &&):

兩層守衛/合併後python
兩層守衛
1 def f(user):
2-    if user:
3-        if user.is_active:
4-            send(user)
5-            log(user)
合併後
1 def f(user):
2+    if user and user.is_active:
3+        send(user)
4+        log(user) 

反過來的 Split if condition 是同一個選單裡的另一項:把 if (a && b) 拆回兩層,通常是因為你要在中間那層插一個 else

7-3 De Morgan:!(a && b) 兩個方向都能走

套用前/套用後ts
套用前
1-if (!(a && b)) {
2   x()
3 }
套用後
1+if (!a || !b) {
2   x()
3 }

兩個方向都提供if (!a || !b) 上按下去會收回成 if (!(a && b)),來回轉會拿到一模一樣的原文。比較運算子不會被硬加否定,而是直接翻面:

套用前/套用後ts
套用前
1-if (!(a === 1 && b > 2)) {
2   x()
3 }
套用後
1+if (a !== 1 || b <= 2) {
2   x()
3 }

Python 用的是它自己的字:if not (ready and done):if not ready or not done:。已經是 if (a && b) 這種沒有否定的條件不會提供這個動作——沒有東西可以分配。

7-4 if 轉條件運算式:每種語言寫法不一樣

同一個動作,產出的是那個語言真正的寫法,不是硬套 ?:

Java:兩個賦值/Java:三元運算子java
Java:兩個賦值
1-if (user.isAdmin()) {
2-    role = "admin";
3-} else {
4-    role = "guest";
5-}
Java:三元運算子
1+role = user.isAdmin() ? "admin" : "guest";    
Python:兩個 return/Python:條件表達式python
Python:兩個 return
1 def role(user):
2-    if user.is_admin:
3-        return "admin"
4-    else:
5-        return "guest"
Python:條件表達式
1 def role(user):
2+    return "admin" if user.is_admin else "guest"   

Kotlin 拿到的是 if-expression(role = if (user.isAdmin) "admin" else "guest"),Rust 是大括號版的 if-expression(role = if user.is_admin { "admin" } else { "guest" };)。兩個分支必須是同一件事——都是 return,或都是賦值給同一個東西——否則不提供。

7-5 Python:.format() / % 轉 f-string

游標放在字串字面值裡 → Convert to an f-string

format()/f-stringpython
format()
1-msg = "Hello, {}!".format(name)
2-row = "{:>8}|{:<8}".format(left, right)
3-title = "Hi {who}".format(who=user.name)
f-string
1+msg = f"Hello, {name}!"
2+row = f"{left:>8}|{right:<8}"
3+title = f"Hi {user.name}"

對齊格式(:>8)與轉換旗標(!r)原封不動搬過去,{1} … {0} 這種明寫位置的也會照著對回正確的引數。

"%d" % value 刻意不轉"%d" % 3.7 的結果是 "3"f"{3.7}""3.7"——這不是格式差異,是值變了。只有 %s%r 轉過去意思完全不變,所以只有這兩個會轉。

八、設定

設定預設說明
refactory.previewmultiFile什麼時候走預覽面板。帶警告的計畫一律強制預覽
refactory.dotnetPath""指定 C# 引擎用的 dotnet 路徑
refactory.disabledLanguages[]要完全避開的語言 id
refactory.smells.react / .csharp各自關掉一半的壞味道檢查
refactory.developerModefalse開啟 Dev: 診斷指令
refactory.keymapnoneriderStyle 會多綁 Ctrl+Alt+M / Ctrl+Alt+V 到 VS Code 內建的抽取動作。預設關掉,因為非美式鍵盤上 Ctrl+Alt+<字母>AltGr+<字母> 分不出來

教它你的專案慣例:.refactory.json

慣例是專案的屬性,不是個人偏好,所以放 repo 根目錄、進版控、全團隊共用。所有欄位都可省略,沒寫的就用自動偵測的

{
  "version": 1,
  "typescript": {
    "components": { "declaration": "exportDefaultFunction", "propsStyle": "interfaceSuffixProps" },
    "hooks": {
      // 先寫特定路徑、最後放 catch-all,第一個符合的贏
      "location": [
        { "when": "src/components/admin/**", "dir": "src/components/admin/_hooks" },
        { "when": "**", "dir": "src/hooks" }
      ],
      "fileNaming": "camelCase"
    }
  },
  "smells": { /* 個別規則關閉或調門檻,id 見 docs/SMELLS.md */ },
  "exclude": ["**/node_modules/**", "**/.next/**"]
}

exclude 同時也管壞味道檢查,所以打開 generated 的 migration 檔不會滿屏波浪線。

九、按了卻「什麼都沒出現」

怎麼操作:設定 "refactory.developerMode": trueCtrl+Shift+PRefactory: Dev: Why Is Nothing Offered Here?

它會直接告訴你:哪份 tsconfig 管這個檔、推導出哪些 alias、語言認不認得、檔案能不能正常解析、游標是不是在字串或註解裡、哪些動作是刻意讓給編輯器內建的。

最常見的元兇是前面有一個沒閉合的字串,會讓後面整個檔案失效。還是想不通,Refactory 這個 output channel 有完整 trace。

注意事項

  • 繁體中文介面:VS Code 語言設繁中就會看到「重構這裡…」「程式碼健康儀表板」
  • C# 需要 .NET 8 runtime;純前端的工作階段根本不會啟動 .NET sidecar
  • Kotlin 要先裝任一個 Kotlin 外掛(VS Code 自己沒註冊 kotlin 語言 id)
  • Ruby 刻意不支援:詞法結構沒有 parser 就是真的有歧義,一張百分之一機率出錯的分析表比不支援更糟
  • TypeScript / JavaScript 上刻意不提供 Introduce variable 和轉插值字串,內建版本懂型別,比較好用
  • 不會拖慢 VS Code:不載入 solution、不載入 TypeScript program,跨檔問題用宣告索引回答(上千檔一秒內建完)

結論

通用的重構各家原廠都有做。但知道你的專案把介面放哪、DI 註冊寫在哪、'use client' 該不該加,這件事只有懂你 repo 的工具做得到。

裝起來按一下 Ctrl+Alt+Shift+T,你就知道少的那一味是什麼了。

作者

Mark Ku

擁有 10+ 年經驗的資深軟體工程師,現為 AI 應用 Builder,專注於大型平台架構與簡化複雜系統設計,從電商系統到訂閱與收費平台,結合 AI Agent、AI 整合與自動化開發,打造高效率且可持續演進的產品技術基礎。閱讀更多

覺得這篇有幫助?

作者做的免費工具、每日 Podcast 與電子報,都在這裡。

Mark Ku · 本文採用 CC BY 4.0 授權,轉載請註明作者並附上原文連結。

留言

訂閱電子報

訂閱後即時收到新文章通知,不錯過任何技術分享。

提交即表示同意接收電子報,隨時可

熱門文章

View all
Mark Ku
··625

Oracle Cloud 永久免費方案 Linux 主機及固定 IP :0 元打造雲端解決方案

Oracle Cloud 永久免費方案 Linux 主機及固定 IP :0 元打造雲端解決方案
Mark Ku
··485

告別 Postman 收費陷阱!開源 Git 原生 API 測試神器 Bruno 實戰指南

告別 Postman 收費陷阱!開源 Git 原生 API 測試神器 Bruno 實戰指南
Mark Ku
··335

一款免費開源類似於 Notion 類知識庫系統 — Outline Wiki 佈署與備份全攻略

一款免費開源類似於 Notion 類知識庫系統 — Outline Wiki 佈署與備份全攻略
Mark Ku
··247

訓練自己的 AI 語音:硬體門檻、開源模型比較與 LoRA 微調

訓練自己的 AI 語音:硬體門檻、開源模型比較與 LoRA 微調
Mark Ku
··232

打造高效 API 管理平台:從 0 開始部署 Kong Gateway - Part 1

打造高效 API 管理平台:從 0 開始部署 Kong Gateway - Part 1
Mark Ku
··208

在 Ubuntu 上設置 Samba 來共享資料夾,讓 Windows 11 用戶可以存取

在 Ubuntu 上設置 Samba 來共享資料夾,讓 Windows 11 用戶可以存取