探索 PWA 的最新和即將推出的瀏覽器功能:出自《Fugu With Love》

1. 事前準備

漸進式網路應用程式 (PWA) 是透過網路提供的應用程式軟體,以 HTML、CSS 和 JavaScript 等常見的網路技術為基礎。適用於任何採用標準瀏覽器的瀏覽器。

在這個程式碼研究室中,您會先從基準 PWA 著手,然後探索新的瀏覽器功能,最終將為 PWA 提供超強 🦸?。

其中有許多新的瀏覽器功能仍在檔期內,且也已標準化處理,因此你需要設定瀏覽器標記才能使用這些功能。

必要條件

在這個程式碼研究室中,您應該熟悉新型的 JavaScript,尤其是承諾與非同步/等待。由於並非所有平台都支援程式碼研究室的所有步驟,因此建議您測試您是否有其他裝置 (例如 Android 手機或筆記型電腦),且使用的作業系統與您編輯程式碼的裝置不同。除了實際裝置之外,您也可以使用 Android 模擬工具等模擬工具或線上堆疊 (例如 StackStack),讓你透過目前的裝置進行測試。或者,您也可以略過任何步驟,這些步驟並不需要彼此。

建構項目

您將建構了一個問候語卡片網頁應用程式,並瞭解全新及即將推出的瀏覽器功能如何強化應用程式功能,以便在某些瀏覽器上提供進階的使用體驗 (但仍能在所有新式瀏覽器上發揮作用)。

您將瞭解如何新增支援功能,例如檔案系統存取權、系統剪貼簿存取、聯絡人擷取、定期背景同步處理、螢幕 Wake Lock 和分享功能等等。

完成程式碼研究室的課程後,您將充分瞭解如何運用新的瀏覽器功能逐步改善網路應用程式,同時減少在不相容的瀏覽器上進行下載的使用者負擔;最重要的是,在一開始就將這些使用者排除在應用程式之外。

軟硬體需求

目前完整支援的瀏覽器包括:

建議您使用特定的開發人員版。

2. Project Fugu 專案

漸進式網路應用程式 (PWA) 是採用先進的 API 所建構與強化的,能夠以更強大的功能提升效能、穩定度和可安裝性,讓您隨時隨地使用各種裝置上網,與世界各地的使用者交流。

部分 API 功能相當強大,如果系統處理錯誤,可能會發生問題。就像豆腐魚一樣 🐡?:剪出對焦後,就會有負面的思緒,但一旦做出錯誤,就會顯得令人不安 (不過別擔心,這個程式碼研究室中並沒有什麼東西能破壞)。

因此,網路功能專案 (相關公司正在開發這些新的 API) 的內部程式碼名稱是 Project Fugu。

現今的網路能力現在已可供大型和中小企業採用純瀏覽器型解決方案,因此相較於平台專屬路徑,通常可以加快部署速度,並加快開發成本。

3. 開始使用

請下載任一瀏覽器,然後瀏覽至 about://flags,以便在 Chrome 和 Edge 中運作,即可設定執行階段標記 🚩?:

  • #enable-experimental-web-platform-features

啟用後,請重新啟動瀏覽器。

您將會使用 Glitch 這個平台來代管 PWA,因為這個平台有完善的編輯器。Glitch 也支援匯入和匯出至 GitHub 的功能,因此不會受制於特定廠商。前往 fugu- Paint.glitch.me 試用應用程式。它是基本的繪圖應用程式 ⋅ 您將在程式碼研究室中改善應用程式。

富翁問候語基準 PWA 搭配大型畫布,上面標示「“Google”」上畫。

播放完應用程式後,您可以重新混用應用程式,建立您自己的複本,供您可以編輯。你的重混作品網址看起來會像這樣:glitch.com/edit/#!/bouncy-candytuft (「bouncy-candytuft」也是您)。這部合輯可直接在世界各地存取。如要儲存你的工作,請登入你的現有帳戶,或是在 Glitch 建立新帳戶。如要查看您的應用程式,請按一下 [🕶? 顯示] 按鈕,代管應用程式的網址會是 bouncy-candytuft.glitch.me (請注意,.me 不是 .com,是頂層網域)。

現在,你可以開始編輯並改善應用程式了。只要你進行變更,應用程式就會重新載入,並直接顯示你所做的變更。

顯示 HTML 文件的編輯 Glitch。

下列工作應按順序完成,但如上所述,如果您無法存取相容的裝置,可以隨時略過這個步驟。請記住,每項工作都會標示 🐟? (無害的淡水魚) 或 🐡?,也就是「保養細心把手」

請查看開發人員工具的主控台,確認目前裝置是否支援 API。我們也採用了 Glitch,方便您在不同裝置上輕鬆查看同一個應用程式,例如手機和桌上型電腦。

已在開發人員工具中記錄至主控台的 API 相容性。

4. 🐟? 新增 Web Share API 支援

沒有人會覺得最受吸引的繪圖無聊。新增這項功能,讓使用者透過賀卡形式向全世界分享他們的繪圖。

Web Share API 支援共用檔案,提醒您,File 只是一種特定的 Blob。因此,在名為 share.mjs 的檔案中,匯入共用按鈕和便利函式 toBlob(),就能將畫布內容轉換成 blob,並依照下方的程式碼新增分享功能。

如果您已經實作了這個 API,但找不到該按鈕,表示您的瀏覽器並未實作 Web Share API。

import { shareButton, toBlob } from './script.mjs';

const share = async (title, text, blob) => {
  const data = {
    files: [
      new File([blob], 'fugu-greeting.png', {
        type: blob.type,
      }),
    ],
    title: title,
    text: text,
  };
  try {
    if (!navigator.canShare(data)) {
      throw new Error("Can't share data.", data);
    }
    await navigator.share(data);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

shareButton.style.display = 'block';
shareButton.addEventListener('click', async () => {
  return share('Fugu Greetings', 'From Fugu With Love', await toBlob());
});

5. 🐟? 新增 Web Share Target API 支援

現在,使用者可以分享透過應用程式建立的賀卡,不過您也可以允許使用者將圖片分享到您的應用程式,然後轉換成賀卡。此時,您可以使用 Web Share Target API

在「網頁資訊清單」中,您必須告知應用程式可以接受哪些檔案類型,以及共用一或多個檔案時,瀏覽器應呼叫哪一個網址。這個檔案的 manifest.webmanifest 摘錄如下。

{
  "share_target": {
    "action": "./share-target/",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "files": [
        {
          "name": "image",
          "accept": ["image/jpeg", "image/png", "image/webp", "image/gif"]
        }
      ]
    }
  }
}

接著,服務工作人員會處理接收的檔案。「./share-target/」這個網址實際上不存在,因此應用程式只會在「fetch」處理常式中處理該網址,並新增查詢參數「?share-target」將要求重新導向至根網址:

self.addEventListener('fetch', (fetchEvent) => {
  /* 🐡 Start Web Share Target */
  if (
    fetchEvent.request.url.endsWith('/share-target/') &&
    fetchEvent.request.method === 'POST'
  ) {
    return fetchEvent.respondWith(
      (async () => {
        const formData = await fetchEvent.request.formData();
        const image = formData.get('image');
        const keys = await caches.keys();
        const mediaCache = await caches.open(
          keys.filter((key) => key.startsWith('media'))[0],
        );
        await mediaCache.put('shared-image', new Response(image));
        return Response.redirect('./?share-target', 303);
      })(),
    );
  }
  /* 🐡 End Web Share Target */

  /* ... */
});

應用程式載入時,會檢查此查詢參數是否已設定;如果是,將共用的圖片繪製到畫布上,並將其從快取中刪除。這一切都是在 script.mjs 中進行:

const restoreImageFromShare = async () => {
  const mediaCache = await getMediaCache();
  const image = await mediaCache.match('shared-image');
  if (image) {
    const blob = await image.blob();
    await drawBlob(blob);
    await mediaCache.delete('shared-image');
  }
};

然後,應用程式會在初始化時使用這個函式。

if (location.search.includes('share-target')) {
  restoreImageFromShare();
} else {
  drawDefaultImage();
}

6. 🐟? 新增匯入圖片支援

從頭繪製內容十分困難。新增一項功能,讓使用者將裝置上的本機圖片上傳到應用程式。

首先,在 Canvas' drawImage() 函式上閱讀本文。接下來,熟悉 <​input
type=file>
元素。

知道這些知識後,您就可以編輯名為 import_image_legacy.mjs 的檔案,並加入下列程式碼片段。只要在檔案頂端匯入匯入按鈕和便利函式 drawBlob(),即可在畫布上畫出 blob。

import { importButton, drawBlob } from './script.mjs';

const importImage = async () => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'image/png, image/jpeg, image/*';
    input.addEventListener('change', () => {
      const file = input.files[0];
      input.remove();
      return resolve(file);
    });
    input.click();
  });
};

importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
  const file = await importImage();
  if (file) {
    await drawBlob(file);
  }
});

7. 🐟? 新增匯出圖片支援

使用者如何將在應用程式中建立的檔案儲存到他們的裝置上?但以往您可以使用 <​a
download>
元素來做到這點。

請在 export_image_legacy.mjs 檔案中新增下列內容。匯入匯出按鈕以及將畫布內容轉換為 blob 的 toBlob() 便利函式。

import { exportButton, toBlob } from './script.mjs';

export const exportImage = async (blob) => {
  const a = document.createElement('a');
  a.download = 'fugu-greeting.png';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', (e) => {
    a.remove();
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  setTimeout(() => a.click(), 0);
};

exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
  exportImage(await toBlob());
});

8. 🐟? 新增 File System Access API 支援

分享行為很關心,但使用者可能想將最精彩的作品儲存在自己的裝置上。新增一項功能,可讓使用者儲存 (或重新開啟) 他們的繪圖。

以往,您採用 <​input type=file> 的舊版方法匯入檔案,使用的是 <​a download> 的舊版匯出方法。現在,請使用 File System Access API 改善使用體驗。

這個 API 可讓您從作業系統的檔案系統開啟及儲存檔案。請在下方新增內容,分別編輯這兩個檔案 (import_image.mjsexport_image.mjs)。如要載入這些檔案,請移除 script.mjs 中的 🐡?表情符號。

取代以下這行程式碼:

// Remove all the emojis for this feature test to succeed.
if ('show🐡Open🐡File🐡Picker' in window) {
  /* ... */
}

...使用這一行:

if ('showOpenFilePicker' in window) {
  /* ... */
}

import_image.mjs 中:

import { importButton, drawBlob } from './script.mjs';

const importImage = async () => {
  try {
    const [handle] = await window.showOpenFilePicker({
      types: [
        {
          description: 'Image files',
          accept: {
            'image/*': ['.png', '.jpg', '.jpeg', '.avif', '.webp', '.svg'],
          },
        },
      ],
    });
    return await handle.getFile();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
  const file = await importImage();
  if (file) {
    await drawBlob(file);
  }
});

export_image.mjs 中:

import { exportButton, toBlob } from './script.mjs';

const exportImage = async () => {
  try {
    const handle = await window.showSaveFilePicker({
      suggestedName: 'fugu-greetings.png',
      types: [
        {
          description: 'Image file',
          accept: {
            'image/png': ['.png'],
          },
        },
      ],
    });
    const blob = await toBlob();
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
  await exportImage();
});

9. 🐟? 新增 Contacts Picker API 支援

使用者可能會想在賀卡中加入訊息,並親自與其他人聯絡。新增一項功能,讓使用者挑選其中一位或多位本機聯絡人,並將對方的名稱加入分享訊息中。

在 Android 或 iOS 裝置上,Contact Picker API 可讓您從裝置的「聯絡人管理員」應用程式挑選聯絡人,然後傳回應用程式。編輯 contacts.mjs 檔案並在下方加入程式碼。

import { contactsButton, ctx, canvas } from './script.mjs';

const getContacts = async () => {
  const properties = ['name'];
  const options = { multiple: true };
  try {
    return await navigator.contacts.select(properties, options);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

contactsButton.style.display = 'block';
contactsButton.addEventListener('click', async () => {
  const contacts = await getContacts();
  if (contacts) {
    ctx.font = '1em Comic Sans MS';
    contacts.forEach((contact, index) => {
      ctx.fillText(contact.name.join(), 20, 16 * ++index, canvas.width);
    });
  }
});

10. 🐟? 新增 Async Clipboard API 支援

使用者可能想將其他應用程式中的圖片貼到您的應用程式中,或是將應用程式繪圖複製到其他應用程式。請新增功能,讓使用者能夠將圖片複製及貼到應用程式中。Async Clipboard API 支援 PNG 圖片,您現在可以讀取圖片資料並複製到剪貼簿。

找出 clipboard.mjs 檔案並新增下列項目:

import { copyButton, pasteButton, toBlob, drawImage } from './script.mjs';

const copy = async (blob) => {
  try {
    await navigator.clipboard.write([
      /* global ClipboardItem */
      new ClipboardItem({
        [blob.type]: blob,
      }),
    ]);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const paste = async () => {
  try {
    const clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
      try {
        for (const type of clipboardItem.types) {
          const blob = await clipboardItem.getType(type);
          return blob;
        }
      } catch (err) {
        console.error(err.name, err.message);
      }
    }
  } catch (err) {
    console.error(err.name, err.message);
  }
};

copyButton.style.display = 'block';
copyButton.addEventListener('click', async () => {
  await copy(await toBlob());
});

pasteButton.style.display = 'block';
pasteButton.addEventListener('click', async () => {
  const image = new Image();
  image.addEventListener('load', () => {
    drawImage(image);
  });
  image.src = URL.createObjectURL(await paste());
});

11. 🐟? 新增 Badging API 支援

使用者安裝您的應用程式時,主畫面上會顯示一個圖示。這個圖示可用來傳達各種趣味資訊,例如某張畫的筆刷數。

新增一項功能,用於在使用者每次刷新時計算徽章。Badging API 可讓您為應用程式圖示設定數值徽章。每當 pointerdown 事件發生 (例如有筆刷時) 時,您都可以更新徽章,並在畫布遭到清除時重設徽章。

請將下列程式碼放入「badge.mjs」檔案:

import { canvas, clearButton } from './script.mjs';

let strokes = 0;

canvas.addEventListener('pointerdown', () => {
  navigator.setAppBadge(++strokes);
});

clearButton.addEventListener('click', () => {
  strokes = 0;
  navigator.setAppBadge(strokes);
});

12. 🐟? 新增 Screen Wake Lock API 支援

使用者有時可能需要稍待片刻,才剛畫出繪圖。新增讓螢幕保持喚醒的功能,避免螢幕保護程式啟動。Screen Wake Lock API 可防止使用者的螢幕進入休眠狀態。每當「瀏覽瀏覽權限」定義的顯示設定變更事件發生時,系統便會自動喚醒 Wake Lock。因此,當網頁回到畫面時,必須重新喚醒 Wake Lock。

尋找 wake_lock.mjs 檔案並新增下列內容。如要測試是否可行,請將螢幕保護程式設為 1 分鐘後顯示。

import { wakeLockInput, wakeLockLabel } from './script.mjs';

let wakeLock = null;

const requestWakeLock = async () => {
  try {
    wakeLock = await navigator.wakeLock.request('screen');
    wakeLock.addEventListener('release', () => {
      console.log('Wake Lock was released');
    });
    console.log('Wake Lock is active');
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const handleVisibilityChange = () => {
  if (wakeLock !== null && document.visibilityState === 'visible') {
    requestWakeLock();
  }
};

document.addEventListener('visibilitychange', handleVisibilityChange);

wakeLockInput.style.display = 'block';
wakeLockLabel.style.display = 'block';
wakeLockInput.addEventListener('change', async () => {
  if (wakeLockInput.checked) {
    await requestWakeLock();
  } else {
    wakeLock.release();
  }
});

13. 🐟? 新增定期 背景同步 API 支援

從空白畫布開始可能會是無聊的。您可以使用 Periodic Background Sync API 初始化使用者,在每天使用新的圖片製作畫布,例如 Un 啟動' 的每日模糊相片

您需要兩個檔案,一個用來註冊定期背景同步處理的檔案 periodic_background_sync.mjs 和另一個可下載當日圖片的檔案 image_of_the_day.mjs

periodic_background_sync.mjs 中:

import { periodicBackgroundSyncButton, drawBlob } from './script.mjs';

const getPermission = async () => {
  const status = await navigator.permissions.query({
    name: 'periodic-background-sync',
  });
  return status.state === 'granted';
};

const registerPeriodicBackgroundSync = async () => {
  const registration = await navigator.serviceWorker.ready;
  try {
    registration.periodicSync.register('image-of-the-day-sync', {
      // An interval of one day.
      minInterval: 24 * 60 * 60 * 1000,
    });
  } catch (err) {
    console.error(err.name, err.message);
  }
};

navigator.serviceWorker.addEventListener('message', async (event) => {
  const fakeURL = event.data.image;
  const mediaCache = await getMediaCache();
  const response = await mediaCache.match(fakeURL);
  drawBlob(await response.blob());
});

const getMediaCache = async () => {
  const keys = await caches.keys();
  return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};

periodicBackgroundSyncButton.style.display = 'block';
periodicBackgroundSyncButton.addEventListener('click', async () => {
  if (await getPermission()) {
    await registerPeriodicBackgroundSync();
  }
  const mediaCache = await getMediaCache();
  let blob = await mediaCache.match('./assets/background.jpg');
  if (!blob) {
    blob = await mediaCache.match('./assets/fugu_greeting_card.jpg');
  }
  drawBlob(await blob.blob());
});

image_of_the_day.mjs 中:

const getImageOfTheDay = async () => {
  try {
    const fishes = ['blowfish', 'pufferfish', 'fugu'];
    const fish = fishes[Math.floor(fishes.length * Math.random())];
    const response = await fetch(`https://source.unsplash.com/daily?${fish}`);
    if (!response.ok) {
      throw new Error('Response was', response.status, response.statusText);
    }
    return await response.blob();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const getMediaCache = async () => {
  const keys = await caches.keys();
  return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};

self.addEventListener('periodicsync', (syncEvent) => {
  if (syncEvent.tag === 'image-of-the-day-sync') {
    syncEvent.waitUntil(
      (async () => {
        try {
          const blob = await getImageOfTheDay();
          const mediaCache = await getMediaCache();
          const fakeURL = './assets/background.jpg';
          await mediaCache.put(fakeURL, new Response(blob));
          const clients = await self.clients.matchAll();
          clients.forEach((client) => {
            client.postMessage({
              image: fakeURL,
            });
          });
        } catch (err) {
          console.error(err.name, err.message);
        }
      })(),
    );
  }
});

14. 🐟? 新增 Shape Detection API 支援

有些使用者可能會認為繪圖和使用中的背景圖片含有實用資訊,例如條碼。Shape Detection API (尤其是 Barcode Detection API) 可讓您擷取這項資訊。新增可偵測使用者的條碼的繪圖和繪圖。找出 barcode.mjs 檔案並新增下列內容。如要測試這項功能,只需將含有條碼的圖片載入或貼到畫布上即可。您可以從搜尋 QR 圖碼的圖片搜尋頁面複製條碼範例。

/* global BarcodeDetector */
import {
  scanButton,
  clearButton,
  canvas,
  ctx,
  CANVAS_BACKGROUND,
  CANVAS_COLOR,
  floor,
} from './script.mjs';

const barcodeDetector = new BarcodeDetector();

const detectBarcodes = async (canvas) => {
  return await barcodeDetector.detect(canvas);
};

scanButton.style.display = 'block';
let seenBarcodes = [];
clearButton.addEventListener('click', () => {
  seenBarcodes = [];
});
scanButton.addEventListener('click', async () => {
  const barcodes = await detectBarcodes(canvas);
  if (barcodes.length) {
    barcodes.forEach((barcode) => {
      const rawValue = barcode.rawValue;
      if (seenBarcodes.includes(rawValue)) {
        return;
      }
      seenBarcodes.push(rawValue);
      ctx.font = '1em Comic Sans MS';
      ctx.textAlign = 'center';
      ctx.fillStyle = CANVAS_BACKGROUND;
      const boundingBox = barcode.boundingBox;
      const left = boundingBox.left;
      const top = boundingBox.top;
      const height = boundingBox.height;
      const oneThirdHeight = floor(height / 3);
      const width = boundingBox.width;
      ctx.fillRect(left, top + oneThirdHeight, width, oneThirdHeight);
      ctx.fillStyle = CANVAS_COLOR;
      ctx.fillText(
        rawValue,
        left + floor(width / 2),
        top + floor(height / 2),
        width,
      );
    });
  }
});

15. 🐡? 新增 Idle Detection API 支援

如果您認為自己的應用程式是以類似 Kiosk 的模式運作,則實用的功能就是在閒置一段時間後重設畫布。Idle Detection API 可讓您偵測使用者是否不再與他們的裝置互動。

尋找 idle_detection.mjs 檔案並貼入下方內容。

import { ephemeralInput, ephemeralLabel, clearCanvas } from './script.mjs';

let controller;

ephemeralInput.style.display = 'block';
ephemeralLabel.style.display = 'block';

ephemeralInput.addEventListener('change', async () => {
  if (ephemeralInput.checked) {
    const state = await IdleDetector.requestPermission();
    if (state !== 'granted') {
      ephemeralInput.checked = false;
      return alert('Idle detection permission must be granted!');
    }
    try {
      controller = new AbortController();
      const idleDetector = new IdleDetector();
      idleDetector.addEventListener('change', (e) => {
        const { userState, screenState } = e.target;
        console.log(`idle change: ${userState}, ${screenState}`);
        if (userState === 'idle') {
          clearCanvas();
        }
      });
      idleDetector.start({
        threshold: 60000,
        signal: controller.signal,
      });
    } catch (err) {
      console.error(err.name, err.message);
    }
  } else {
    console.log('Idle detection stopped.');
    controller.abort();
  }
});

16. 🐡? 新增 File Handling API 支援

如果您的使用者只有 doubleclick 這個圖片檔案,而應用程式會顯示彈出式視窗,該怎麼辦?File Handling API 可讓您執行上述作業。

您將 PWA 註冊為圖片檔案處理常式。這發生在網路應用程式資訊清單中,檔案 manifest.webmanifest 的摘錄如下。(這已經是資訊清單的一部分,不需要自行新增)。

{
  "file_handlers": [
    {
      "action": "./",
      "accept": {
        "image/*": [".jpg", ".jpeg", ".png", ".webp", ".svg"]
      }
    }
  ]
}

如要實際處理開啟的檔案,請將下列程式碼加入 file-handling.mjs 檔案:

import { drawBlob } from './script.mjs';

const handleLaunchFiles = () => {
  window.launchQueue.setConsumer((launchParams) => {
    if (!launchParams.files.length) {
      return;
    }
    launchParams.files.forEach(async (handle) => {
      const file = await handle.getFile();
      drawBlob(file);
    });
  });
};

handleLaunchFiles();

17. 恭喜

🎉? 太棒了!

Project Fugu 🐡? 開發了許多令人驚豔的瀏覽器 API,讓這個程式碼研究室幾乎無法輕易上手。

如要深入瞭解相關資訊,或者只是想瞭解詳情,請在我們的網站 web.dev 上追蹤我們的發布內容。

網站「web.dev」中「&ldquo;Capability&rdquo;」部分的到達網頁。

但不僅如此,對於尚未公開的更新,您可以使用 Fugu API 追蹤程式,取得所有已出貨的提案連結、原本處於試用階段或開發階段的測試、開始執行的所有提案,以及尚未考慮到但尚未開始的所有提案。

Fugu API 追蹤網站

這個程式碼研究室是由 Thomas Steiner (@tomayac) 所撰寫,我們很樂意回答您的問題,也期待收到您的意見回饋!特別感謝 Hemanth H.M (@GNUmanth)、Christian Liebel (@christianliebel)、Sven May (@Svenmay)、Las Knudsen (@larsgk) 和 Jackie Han (@hanguokai) 對這項程式碼研究室的貢獻做出貢獻!