背景介绍

项目团队设计规范变更,之前定义好的一些颜色有部分修改,需要将现有代码库里硬编码的颜色修改成符合新规范,这部分工作其实非常的机械化。

我们之前接入了 WindiCSS,也配置了一部分颜色 token。但其实最后落地的并不是很好,可能有两个原因:

  1. 设计侧本身就没有太遵守规范,也没有对研发去强调这个规范
  2. 我们的开发习惯,大多是直接从 figma 复制 CSS,并不会自己主动去查看这个颜色是属于哪个 token 然后手动去引用。

这导致了设计规范有变更,我们就要从代码里去查找替换。

这里的一个想法是,我们希望在不改变大家开发习惯的同时,对于明确在规范中的颜色,尽可能的通过变量来引用,这样未来即使设计规范再次变更,我们也只需要修改一个地方,而不用全局的去查找替换。

实现这个想法的方式有很多种,例如编写一个脚本,或者开发一个 ESLint 插件等等。但我这一次选择的是开发一个 VS Code 扩展来做这个事情。

创建扩展

全局安装两个包

npm install -g yo generator-code

其中 yo 这个包是一个生成器,generator-code 是一个类似模板这样的东西。 你可以通过 yo 或者 yo code 来快速创建一个 VS Code 扩展项目,在这里我选的是第一个 New Extension (TypeScript)

接下来会让你做一些选择,包括包管理器等等,然后自动帮你创建目录并且安装依赖。

开发环境

在你创建项目之后,npm install 安装依赖。你的插件代码都在 src/extension.ts 这个文件里。

插件的启动时机,插件功能(贡献点),需要你在 package.jsonactivationEventscontributes 下声明,例如脚手架默认的:

{
  // .....
  "activationEvents": [
     "onCommand:vscode-ext-demo.helloWorld" // 在调用这条命令时启动
  ],
  "contributes": {
     "commands": [ // 提供了一条命令
        {
           "command": "vscode-ext-demo.helloWorld",
           "title": "Hello World"
        }
     ]
   },
}

然后按 F5,就可以启动一个窗口调试你的扩展。这里的原理是,.vscode 目录下有一个 launch.jsontasks.json 文件,VS Code 内置的调试工具和任务执行工具会认这两个文件作为配置。

// launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run Extension",
      "type": "extensionHost",
      "request": "launch",
      "args": [
        "--extensionDevelopmentPath=${workspaceFolder}"
      ],
      "outFiles": [
        "${workspaceFolder}/out/**/*.js"
      ],
      "preLaunchTask": "${defaultBuildTask}" // 开始调试前,先调用默认的 task
    }
}
// tasks.json
{
  "version": "2.0.0",
  "tasks": [
    {
      "type": "npm",
      "script": "watch", // 指向 npm run watch
      "problemMatcher": "$tsc-watch",
      "isBackground": true,
      "presentation": {
        "reveal": "never"
      },
      "group": {
        "kind": "build",
        "isDefault": true
      }
    }
  ]
}

脚手架默认使用 tsc 编译。但其实我习惯用别的工具来编译,因此我们可以做下改造。

改造

介绍下我使用的几个工具包:

  • tsup,基于 esbuild 的打包工具,由国人 egoist 开发
  • tsx,这里 tsx 不是指 react 的 tsx,而是 TypeScript Execute, 一个同样是基于 esbuild 的 ts 文件执行工具
  • npm-run-all,运行 npm scripts 的工具,用这个的目的是我想使用一条命令启动两个 watch 进程,如果直接通过 npm run watch:a && npm run watch:b 是顺序执行的,那么 watch:b 是不会被执行的,这个包提供了一个 run-p 命令可以并行的执行 npm scripts.

于是我们的开发脚本变成了

{
  "scripts": {
    "dev": "run-p dev:*",
    "dev:extension": "tsup --watch",
    "dev:package": "tsx watch scripts/update-package.ts",
  }
}

这里多了一个 update-package.ts 文件,它会去更新 package.json 文件。 前面说了插件提供的功能需要在 package.json 里声明,但是我不想在开发的时候代码里加了一个命令,还要手动在 package.json 里添加,因此提供了一个脚本来做个事情。

API & samples

VS Code 暴露给扩展的 API References 写的其实不是很好,直接看这个的话你会不知道从哪里入手。 VS Code 提供了一个 vscode-extension-samples 项目作为参考,一般都是参考这个项目里类似的扩展是怎么写的,它会提供一个 API 使用示例。

例如这个扩展是和「保存时修改代码」或者「代码格式化」相关的,因此可以参考 code-action-sample

另外,在开发过程中需要去寻找一些 API 时,我一般也不会跳去看文档,而是直接跳转到 vscode 类型定义文件里搜关键字去看 API,类型定义文件里注释很全,他们的网页文档其实也是用这里面的注释生成的。

功能实现

需求

  • 代码里的字面量的颜色,如果在配置的颜色 token 中需要划线提示
  • 保存时,需要自动替换成 CSS 变量
  • 需要支持 alpha,即 #111111rgba(100, 100, 100, 0.5) 都能够转换
  • 支持自定义配置

思路

在打开任意文件和输入时,监听文件内容,扫描出字面量的颜色,从配置里查找是否有这个颜色, 需要注意颜色有多种不同的表示方法,例如 rgb, rgba, hex(我们都只用这三个)等。如果有的话,使用 VS Code 的 API 给颜色添加波浪线,鼠标放上去可以展示修改建议和手动 Fix。

在用户保存时,对前面检测到的波浪线的内容,自动替换成 CSS 变量。

CSS 变量声明有特殊约定,不是直接声明成 hex 或者 rgba, 而是声明成下面这种格式:

--light-gray-10: 6, 20, 41;

这样可以通过 rgb(var(--light-gray-10)) 或者 rgba(var(--light-gray-10), 0.1) 支持不同的 alpha 值,而不用针对每个 alpha 都声明一个变量。

原理

对于这类修改代码的需求,VS Code APIs 提供了 CodeActionDiagnostic 两个模块。其中 CodeAction 可以用来修改代码,或者鼠标放在某代码片段上的时候展示一个命令等等。而 Diagnostic 则是诊断代码,可以给代码添加波浪线,展示错误提示之类的。

两个模块相互配合,Diagnostic 负责提供数据或者信息,CodeAction 负责代码修改。

代码实现

VS Code 的 API 非常的 OOP,因此我们可以很容易的通过继承的方式扩展。

先看最外层的代码,基本上就是我们的代码框架了。

export function activate(context: vscode.ExtensionContext) {
  // 创建一个 DiagnosticCollection 并监听文档变更
  const colorDiagnosticCollection =
    vscode.languages.createDiagnosticCollection('color-changer')
  subscribeToDocumentChanges(context, colorDiagnosticCollection)
  // push 进 subscriptions, 扩展销毁时会清理,就像 useEffect 的返回值一样
  context.subscriptions.push(colorDiagnosticCollection)
  context.subscriptions.push(
      // 注册 CodeActionsProvider, 需要指定支持哪些语言和一个 CodeActionProvider 实例
      vscode.languages.registerCodeActionsProvider(languages, new ColorCodeActionProvider(), {
        providedCodeActionKinds: ColorCodeActionProvider.providedCodeActionKinds,
      })
  )
}

这里没什么特别的,主要逻辑其实都在subscribeToDocumentChangesColorCodeActionProvidersubscribeToDocumentChanges 的逻辑其实也很简单,就是监听各种文档变更,然后调用 refreshDiagnostics

export function subscribeToDocumentChanges(
  context: vscode.ExtensionContext,
  colorDiagnostics: vscode.DiagnosticCollection
): void {
  if (vscode.window.activeTextEditor) {
    refreshDiagnostics(vscode.window.activeTextEditor.document, colorDiagnostics)
  }
  context.subscriptions.push(
    vscode.window.onDidChangeActiveTextEditor(editor => {
      if (editor) {
        refreshDiagnostics(editor.document, colorDiagnostics)
      }
    })
  )
  context.subscriptions.push(
    vscode.workspace.onDidChangeTextDocument(e => refreshDiagnostics(e.document, colorDiagnostics))
  )
  context.subscriptions.push(
    vscode.workspace.onDidCloseTextDocument(doc => colorDiagnostics.delete(doc.uri))
  )
}

refreshDiagnostics 的逻辑就是

export async function refreshDiagnostics(
  doc: vscode.TextDocument,
  colorDiagnostics: vscode.DiagnosticCollection
) {
  if (!languages.includes(doc.languageId)) {
    return
  }
  const diagnostics: vscode.Diagnostic[] = []
  for (let lineIndex = 0; lineIndex < doc.lineCount; lineIndex++) {
    const lineOfText = doc.lineAt(lineIndex)
    const result = extractColors(lineOfText.text)
    result.forEach(colorInfo => {
      // 从配置文件中获取颜色 token,具体实现这里先忽略
      const token = getToken(colorInfo.color)
      if (!token) {
        return
      }
      const range = new vscode.Range(lineIndex, colorInfo.start, lineIndex, colorInfo.end)
      diagnostics.push(
        ColorDiagnostic.create(range, {
          ...colorInfo,
          token: token,
        })
      )
    })
  }
  // 将收集到的 diagnostics 全部存到 colorDiagnostics 里
  colorDiagnostics.set(doc.uri, diagnostics)
}

这里比较关键的是 extractColors 和 ColorDiagnostic.create

extractColors 方法就是从每一行的文本当中,提取出类似颜色的字符串,这里匹配的方式很简单,就是利用正则,然后会返回颜色代码片段的起始位置、结束位置,颜色值等信息。

export type ColorInfo = {
  color: string
  format: ColorFormat
  start: number
  end: number
  token: ColorToken
}
const regExps: Record<ColorFormat, () => RegExp> = {
  rgba: () => /rgba(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*((\d)|(0?.\d{1}))\s*)/gi,
  rgb: () => /rgb(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*)/gi,
  hex: () => /#(([abcdefABCDEF\d]{6,6})|([abcdefABCDEF\d]{3,3}))/gi,
}
export function extractColors(text: string): Array<Omit<ColorInfo, 'token'>> {
  const result: Array<Omit<ColorInfo, 'token'>> = []
  for (const format of formats) {
    const re = regExps[format]()
    const res = [...text.matchAll(re)] // 匹配所有颜色
    if (!res.length) {
      continue
    }
    res.forEach(match => {
      const color = match[0].trim()
      const start = match.index!
      const end = start + color.length
      result.push({
        color,
        format,
        start,
        end,
      })
    })
  }
  return result
}

ColorDiagnostic 这个类则是提供一些信息,比如传给父类 vscode.DiagnosticSeverity.Warning 会展示一条警告,鼠标放在代码上或者 PROBLEMS 面板里还会展示 message, code 等

// 继承自 vscode.Diagnostic
export class ColorDiagnostic extends vscode.Diagnostic {
  colorInfo: ColorInfo
  // fixCode 表示自动格式化后的代码
  fixCode: string
  static create(range: vscode.Range, colorInfo: ColorInfo) {
    return new ColorDiagnostic(range, colorInfo)
  }
  static isInstance(v: any): v is ColorDiagnostic {
    return v instanceof ColorDiagnostic
  }
  constructor(range: vscode.Range, colorInfo: ColorInfo) {
    const token = colorInfo.token
    const alpha = Color(colorInfo.color).alpha()
    const fixCode = `rgba(var(--${token?.name}), ${alpha})`
    const message = `Replace ${colorInfo.color} to ${fixCode}`
    super(range, message, vscode.DiagnosticSeverity.Warning)
    this.colorInfo = colorInfo
    this.code = 'color-changer'
    this.fixCode = fixCode
  }
  // 创建一个 fix 的 codeAction, 后面 codeActionProvider 用到
  createFix(document: vscode.TextDocument, kind: vscode.CodeActionKind) {
    const fix = new vscode.CodeAction(`Fix: change to ${this.fixCode}`, kind)
    fix.edit = this.createEdit(document)
    fix.isPreferred = true
    return fix
  }
  private createEdit(document: vscode.TextDocument) {
    const edit = new vscode.WorkspaceEdit()
    // 修改代码
    edit.replace(document.uri, this.range, this.fixCode)
    return edit
  }
}

然后 CodeActionProvider 的逻辑很简单,实现 provideCodeActions,将收集到的诊断信息返回一个 CodeAction 数组,VS Code 会去调用这个方法拿到其返回值。

同一个代码片段可以同时有多个 CodeAction,例如我这里对于同一个 diagnostic,会创建两个 CodeAction。其中 vscode.CodeActionKind.QuickFix 要手动点才能 fix,vscode.CodeActionKind.SourceFixAll 可以配置保存时自动格式化。

export class ColorCodeActionProvider implements vscode.CodeActionProvider {
  public static readonly providedCodeActionKinds = [
    vscode.CodeActionKind.QuickFix,
    vscode.CodeActionKind.SourceFixAll,
  ]
  provideCodeActions(
    document: vscode.TextDocument,
    range: vscode.Range | vscode.Selection,
    context: vscode.CodeActionContext
    // token: vscode.CancellationToken
  ): vscode.CodeAction[] {
     // 所有的 diagnostics 都可以拿到,例如 eslint 之类的其他代码检查工具
     return context.diagnostics
      .filter(ColorDiagnostic.isInstance) // 我们是格式化颜色,只过滤出我们关心的 ColorDiagnostic
      .reduce<vscode.CodeAction[]>((actions, diagnostic) => {
        actions.push(
            diagnostic.createFix(document, vscode.CodeActionKind.QuickFix)
        )
        actions.push(
            diagnostic.createFix(document, vscode.CodeActionKind.SourceFixAll)
        )
        return actions
      }, [])
  }
}

最核心的功能实现就上面这些代码。

颜色配置

我们希望可以由用户提供一份配置,这样不同的项目或者业务都可以使用这个插件。引入配置后,我们不仅仅需要处理配置解析和读取的逻辑,还要处理配置更新后,我们状态也要更新。

VS Code 提供了 Configuration API, 可以在 package.json 里的 contributes 里配置我们声明了哪些配置

"contributes": {
    "configuration": {
      "type": "object",
      "title": "color-changer",
      "properties": {
        "color-changer.colorrc": {
          "type": "string",
          "default": "${workspaceFolder}/colorrc.json",
          "description": "json rules config file path, default is '${workspaceFolder}/colorrc.json'"
        }
      }
    }
  },

配置后代码就可以通过 vscode.workspace.getConfiguration().get<string>('color-changer.colorrc') 拿到配置的值。

export class Config {
  tokens: Record<string, ColorToken> = {}
  async refresh(toast = false) {
    const workspaceRoot = vscode.workspace.workspaceFolders![0].uri
    let rcFilePath =
      vscode.workspace.getConfiguration().get<string>(ConfigName.colorrc) ||
      `${workspaceRoot.path}/colorrc.json`
    // 替换占位符
    rcFilePath = rcFilePath.replace('${workspaceFolder}', workspaceRoot.path)
    try {
      // 解析配置文件内容
      const buffer = await vscode.workspace.fs.readFile(vscode.Uri.file(rcFilePath))
      const content = new TextDecoder().decode(buffer)
      const rules = JSON.parse(content) as Rules
      this.tokens = Object.keys(rules).reduce((res, key) => {
        res[key] = {
          name: key,
          value: Color(rules[key].value).hex(), // 全部转换成hex形式容易比较
        }
        return res
      }, {} as any)
      console.info('[Color Changer]: Tokens have been refreshed!')
      if (toast) {
        vscode.window.showInformationMessage('[Color Changer]: Tokens have been refreshed!')
      }
      return true // 返回 true 表示成功,false 表示失败
    } catch (error) {
      console.error('[Color Changer]: Resolve color rules error', error)
      if (toast) {
        vscode.window.showErrorMessage('[Color Changer]: Resolve color rules error.')
      }
      return false
    }
  }
  get(value: string) {
    const keys = Object.keys(this.tokens)
    const color = Color(value).alpha(1)
    const hex = color.hex()
    for (const key of keys) {
      if (this.tokens[key].value === hex) {
        return this.tokens[key]
      }
    }
    return null
  }
}

然后我们就可以在 extension.ts 里加一个监听配置文件更新的逻辑:

context.subscriptions.push(
    vscode.workspace.onDidChangeConfiguration(evt => {
      if (evt.affectsConfiguration(ConfigName.colorrc)) {
        config.refresh(true) // 重新生成 tokens
      }
    })
)

我们前面收集 diagnostics 的逻辑也可以从 config 上获取到对应颜色字面量的的 token 定义

export async function refreshDiagnostics(
  doc: vscode.TextDocument,
  colorDiagnostics: vscode.DiagnosticCollection
) {
  // ...
  const result = extractColors(lineOfText.text)
  result.forEach(colorInfo => {
      const token = config.get(colorInfo.color)
      // ...
  })
  // ...
}

忽略注释

对于一些出现在注释里的代码,例如: // #123456 或者 /* #123456 */ ,我们不期望这些颜色被格式化,需要额外做一些处理。

在我们对文件中每一行的文本提取颜色时,我们可以找出这一行当中注释的开始坐标和结束坐标,如果一个颜色在注释的范围内,我们就忽略它.

export function extractColors(text: string): Array<Omit<ColorInfo, 'token'>> {
  const result: Array<Omit<ColorInfo, 'token'>> = []
  // 找出所有注释
  const comments = getComments(text)
  for (const format of formats) {
    // ....
    res.forEach(match => {
      // 在注释内就忽略
      if (comments.some(comment => isInComment({ start, end }, comment))) {
        return
      }
      // ....
    })
  }
  return result
}
function getComments(line: string) {
  const comments: Array<Comment> = []
  let blockCommentStart = -1
  let i = 0
  while (i < line.length) {
    const curChar = line[i]
    const nextChar = line[i + 1]
    if (blockCommentStart > -1) {
      // 寻找块注释结束字符 `*/`
      if (curChar === '*' && nextChar === '/') {
        comments.push({
          start: blockCommentStart,
          end: i + 1,
        })
        blockCommentStart = -1
      }
      i++
      continue
    }
    // 如果找到了 `//`,那后面的全部都是注释了
    if (curChar === '/' && nextChar === '/') {
      comments.push({ start: i, end: line.length })
      return comments
    }
    // 如果找到了 `/*`, 那我们就需要去找结束的 `*/`
    if (line[i] === '/' && nextChar === '*') {
      blockCommentStart = i
      i += 2
      continue
    }
    i++
  }
  if (blockCommentStart > -1) {
    comments.push({
      start: blockCommentStart,
      end: line.length,
    })
  }
  return comments
}
// 判断一个区域是否在注释范围内
function isInComment(range: Range, comment: Comment) {
  return range.start > comment.start && range.end < comment.end
}

按需开启

现在扩展激活时机是在

"activationEvents": [
    "onLanguage:css",
    "onLanguage:less",
    "onLanguage:scss",
    "onLanguage:postcss",
    "onLanguage:javascript",
    "onLanguage:javascriptreact",
    "onLanguage:typescript",
    "onLanguage:typescriptreact",
    "onCommand:color-changer.refresh"
],

如果一个项目没有颜色配置文件也不打算使用这个扩展,这个扩展也会激活然后持续监听,这不可避免也会有一些开销,除非让用户手动把这个扩展禁用,但显然这样太麻烦了,我们需要一个按需开启的机制。

let isStarted = false
// 核心的监听逻辑移动到这里了
function start(context: vscode.ExtensionContext) {
  if (isStarted) {
    return
  }
  isStarted = true
  const colorDiagnosticCollection =
    vscode.languages.createDiagnosticCollection('color-changer')
  context.subscriptions.push(colorDiagnosticCollection)
  subscribeToDocumentChanges(context, colorDiagnosticCollection)
  context.subscriptions.push(
    vscode.languages.registerCodeActionsProvider(languages, new ColorCodeActionProvider(), {
      providedCodeActionKinds: ColorCodeActionProvider.providedCodeActionKinds,
    })
  )
  context.subscriptions.push(
    vscode.workspace.onDidChangeConfiguration(evt => {
      if (evt.affectsConfiguration(ConfigName.colorrc)) {
        config.refresh(true)
      }
    })
  )
}
export async function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(
    vscode.commands.registerCommand(Command.Refresh, async () => {
      const isSuccess = await config.refresh(true)
      // 手动调用命令时再检查一次
      if (!isSuccess) {
        return
      }
      start(context)
    })
  )
  const isSuccess = await config.refresh(false)
  if (!isSuccess) {
    // 如果失败,说明项目里没有颜色配置文件,就不开启监听
    return false
  }
  start(context)
}

扩展冲突

我们开发时都会安装 eslint, 但 eslint 也提供了代码格式化的功能。

如果两个插件同时进行格式化,因为诊断信息的坐标是在格式化之前就计算好了。如果前一个扩展格式化代码后,导致坐标改变了,那么后一个扩展再使用原坐标信息格式化就不对了。

这个没想到好的解决办法,一个 workaround 就是检测到有 eslint/prettier 格式化相关的诊断信息时,不返回自动格式化的 CodeAction。这样的话:

  • 第一次保存文件时,会调用 eslint 的格式化,不会调用我们扩展的格式化
  • 第二次保存文件时,eslint 的格式化都已经被修复了,然后调用我们扩展的格式化

因为和影响代码格式相关的大多是 prettier 的规则,所以可以简单过滤掉 prettier 的诊断信息

provideCodeActions(
    document: vscode.TextDocument,
    _range: vscode.Range | vscode.Selection,
    context: vscode.CodeActionContext
    // token: vscode.CancellationToken
  ): vscode.CodeAction[] {
    const hasPrettierProblem = context.diagnostics.some(item => isPrettierProblem(item))
    return context.diagnostics
      .filter(ColorDiagnostic.isInstance)
      .reduce<vscode.CodeAction[]>((actions, diagnostic) => {
        actions.push(diagnostic.createFix(document, vscode.CodeActionKind.QuickFix))
        if (!hasPrettierProblem) {
          actions.push(diagnostic.createFix(document, vscode.CodeActionKind.SourceFixAll))
        }
        return actions
      }, [])
  }
function isPrettierProblem(diagnostic: vscode.Diagnostic) {
  const code = diagnostic.code
  const source = diagnostic.source
  return (
    source?.includes('prettier') || (code as any)?.value?.includes('prettier') // for eslint-plugin-prettier
  )
}

打包

功能都实现完了后,我们要进行扩展打包。扩展打包 VS Code 提供了 vsce 这个工具。

vsce package

前面特意强调了用 npm 安装依赖,如果使用 pnpm 安装依赖,node_modules里都是软链,vsce 打包会报错。因为 vsce 打包时会将 node_modules 一起打进去,除非加参数 --no-dependencies, 但是这样的话就要把第三方依赖也打包进来了,见 issue。如果使用 yarn,打包时也要加上 --yarn 。于是最简单的方案就是切换到 npm 了。

另外还有一个坑点是,vsce 打包 node_modules 也只会打包 dependencies 里的依赖,devDependencies 里是不认的。如果你的依赖加错了地方,你会发现你本地调试的时候是正常的,然后打包后安装使用,就用不了了, debug 时间++++。

我最终的打包 scripts

"scripts": {
    "package": "npm run build && vsce package",
    "minor": "npm version minor && npm run package",
    "patch": "npm version patch && npm run package",
    // ...
 }

运行 npm run package, 根目录下就会出现一个 .vsix 文件,右键安装就可以直接使用了。

完。