此文粗略記錄用 React+TypeScript+Firebase 實現一個用來統計 Gitlab Spent Time 的 Chrome Extension 的過程。
內容包括:
- 背景
- 功能介紹
- 如何使用
- 用 Webpack 配置多個 js 入口
- 使用 TypeScript
- 把它變成 Chrome extension
- 使用 Firebase Auth 實現註冊登入
- 使用 Firestore 存取資料
專案地址: GitHub
背景
當初想寫這個擴充套件的動機,是源於我們公司將專案管理平臺從 Redmine 切換到了 GitLab,GitLab 相比 Redmine 確實更加 fashion,但它有一個我們很需要的功能卻不完善,就是時間統計報表,我們需要為每一個 issue 記錄所花費的時間,在 Redmine 上,PM 可以方便地查詢和生成每個人在某個時間段花費的時間報表,但 GitLab 不行,因此 PM 很頭疼,於是想到寫這個外掛來減輕他們的痛苦。
我們試過一些第三方工具,比如 gtt,但這些工具一是耗時很長 (都是通過 GitLab API 先遍歷 projects,再遍歷 project 下的 issues,最後遍歷 issue 下的 time notes),二是對於 PM 來說,使用太複雜 (gtt 是一個命令列工具,而且引數眾多)。
當然,其實最後 PM 們也沒用上我這個工具,因為後來發現了更簡單的辦法,通過查閱 GitLab 的原始碼,發現實際上在 GitLab 的 database 中,是有一個叫 timelogs 的表,直接存放了 time notes,但是很遺憾 GitLab 並沒有開放任何 API 去訪問這個表,於是我們寫了一個 rails 的專案,直接去訪問 GitLab 的 database 來生成報表 (這個專案還在內部完善中)。
雖然如此,我還是通過這個專案學習到了很多,學習到了 TypeScript 的使用,Firebase 的使用,加深了對 Webpack 的理解。我會把它作為我的 side project 繼續優化。
功能介紹
(用星號隱去了一些真實資訊)
-
在每一個 issue page 為每一個 issue 生成實時的 spent time 報表
-
為所有專案和使用者生成實時的 spent time dashboard
-
一個快速 log 今天的 spent time 的按鈕,用來解決時區問題,如果伺服器佈署在另一個相距較遠的時區
如何使用
因為這個擴充套件推薦在各個公司內部自己使用,所以並沒有釋出到 Chrome Store。
如果你想嘗試或有這個需求,可以看這個文件:
用 Webpack 配置多個 js 入口
我們用 React 來實現這個擴充套件,網上搜到的用 React 來實現 Chrome extension 的示例都是用 create-react-app 腳手架來寫的,但由於這個擴充套件需要兩個 js 檔案,一個用來注入到每個 gitlab 的 issue 頁面,一個用來展示 dashboard page。但 create-react-app 只能輸出一個 js 檔案,而通過 yarn eject
出來的 webpack.config.js 太複雜了,所以只好手動配置 Webpack,輸出兩個 js 檔案。
這裡用的是 Webpack4,整個配置過程在 tag webpack4_boilerplate
上可以看到,參考了之前的筆記:
多個輸出的配置:
// webpack.config.js
module.exports = {
entry: {
dashboard: './src/js/dashboard.tsx',
'issue-report': './src/js/issue-report.tsx',
},
output: {},
...
}
複製程式碼
配置了兩個 js 入口,output 選項為空保持預設,這樣輸出會放到預設資料夾 dist 中,輸出的 js 檔名與 entry 中定義的 key 同名。這樣從 dashboard.tsx 入口開始的程式碼將會打包成 dashboard.js,從 issue-report.tsx 入口開始的程式碼將會打包成 issue-report.js,
其它的配置都是常規配置,比如用 sass-loader, postcss-loader, css-loader 以及 mini-css-extract-plugin 處理 css,用 url-loader 和 file-loader 處理圖片和字型檔案等。
用 html-webpack-plugin 外掛生成 dashboard.html。因為 dashboard.html 需要執行 dashboard.js,所以用 chunks
選項宣告此 html 需要載入 dashboard.js。
// webpack.config.js
module.exports = {
...
plugins: [
new HtmlWebpackPlugin({
template: './src/html/template.html',
filename: 'dashboard.html',
chunks: ['dashboard'],
hash: true
}),
...
}
複製程式碼
因為我們是用 React 來實現,所以還要配置處理 .jsx
的 loader,我們用 babel-loader 以及相應的 "env" 和 "react" preset 來處理 .jsx
。
// webpack.config.js
module.exports = {
...
module: {
rules: [
{
test: /\.jsx?$/,
use: 'babel-loader',
include: /src/,
exclude: /node_modules/
},
...
}
// .babelrc
{
"presets": [
"env",
"stage-0",
"react"
]
}
複製程式碼
"stage-0" 是用來轉換 ES7 語法 (比如 async/await) 的,在這裡並不是必需的。
注意,這裡所有用到的 npm 包都需要自己手動通過 npm install
安裝的。
使用 TypeScript
引入 TypeScript 純粹是想練手,通過實踐來熟悉 TypeScript 的使用,一直久聞大名卻沒有機會使用。事實證明確實好用,後來在工作上的專案中也用上了 TypeScript。
React & TypeScript 的官方配置教程:React & Webpack
主要要安裝 typescript 和 awesome-typescript-loader,後者是處理 .tsx
的 loader。
Webpack 的配置:
// webpack.config.js
module.exports = {
...
module: {
rules: [
...
// All files with a '.ts' or '.tsx' extension will be handled by 'awesome-typescript-loader'.
{ test: /\.tsx?$/, loader: "awesome-typescript-loader" },
}
複製程式碼
TypeScript 的配置檔案:
// tsconfig.json
{
"compilerOptions": {
"outDir": "./dist/",
"sourceMap": true,
"strict": true,
"module": "commonjs",
"target": "es6",
"jsx": "react"
},
"include": [
"./src/**/*"
]
}
複製程式碼
實際我們使用 TypeScript 後,就只剩 .tsx
和 .ts
檔案了,不再有 .jsx
檔案,所以處理 .jsx?$
的 rule 其實可以不再需要了。
把它變成 Chrome extension
如上一頓操作後,在 chrome_ext
目錄中執行 npm run build
後就會產生輸出到 dist 目錄中,雙擊 dashboard.html 就可以在瀏覽器中開啟了,或者執行 npm run dev
啟動 webpack-dev-server,然後在瀏覽器中訪問 http://localhost:8080/dashboard.html,dashboard page 已經可以單獨工作了。
但 issue-report.js 不能單獨執行,必須要注入到 gitlab issue 頁面才能執行。我們來宣告一個 manifest.json 把這個應用變成外掛。
新建 public
目錄,在此目錄下放置外掛所需的 manifest.json 宣告檔案以及 icons。
這是初版的 manifest.json:
{
"name": "GitLab Time Report",
"version": "0.1.6",
"version_code": 16,
"manifest_version": 2,
"description": "report your gitlab spent time",
"icons": {
"128": "icons/circle_128.png"
},
"browser_action": {
"default_icon": "icons/circle_128.png"
},
"author": "baurine",
"options_page": "dashboard.html",
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["issue-report.js"],
"css": ["issue-report.css"]
}
],
"permissions": [
"storage"
]
}
複製程式碼
主要是兩個選項,content_scripts
和 options_page
,前者用來宣告需要在哪些頁面注入哪些 js 程式碼以及用到的 css 程式碼,因為這個外掛支援不同的域名,所以 matches
的值是所有 url。options_page
用來宣告右鍵單擊擴充套件圖示後,在彈出的選單中選擇 options 後要開啟的頁面,我們用它來進入 dashboard page。
後來我覺得這個需要兩步操作才能進入 dashboard page,於是改成了單擊滑鼠左鍵後直接開啟 dashboard page,但實現起來稍顯麻煩一點,先來看新的 manifest.json 吧。
{
"name": "GitLab Time Report",
"version": "0.1.7",
"version_code": 17,
"manifest_version": 2,
"description": "report your gitlab spent time",
"author": "baurine",
"icons": {
"128": "icons/circle_128.png"
},
"browser_action": {
"default_icon": "icons/circle_128.png"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["issue-report.js"],
"css": ["issue-report.css"]
}
],
"background": {
"scripts": ["background.js"],
"persistent": false
},
"permissions": [
"storage",
"tabs"
]
}
複製程式碼
我們移除掉了 options_page
選項,增加了 background
選項,background
選項用來宣告在後臺執行的 js 程式碼,後臺 js 程式碼不會被注入到 web 頁面中,也不需要 html。它可以用來監聽瀏覽器的行為以及呼叫 Chrome 瀏覽器的 extension API 來操作瀏覽器,比如開啟一個新的 tab。這裡 background.js 的工作就是監聽瀏覽器點選此擴充套件圖示的事件,然後開啟 tab 去載入 dashboard.html。
程式碼很簡短,如下所示:
// background.js
// ref: https://adamfeuer.com/notes/2013/01/26/chrome-extension-making-browser-action-icon-open-options-page/
const OPTIONS_PAGE = 'dashboard.html'
function openOrFocusOptionsPage() {
const optionsUrl = chrome.extension.getURL(OPTIONS_PAGE)
chrome.tabs.query({}, function (extensionTabs) {
let found = false
for (let i = 0; i < extensionTabs.length; i++) {
if (optionsUrl === extensionTabs[i].url) {
found = true
chrome.tabs.update(extensionTabs[i].id, { "selected": true })
break
}
}
if (found === false) {
chrome.tabs.create({ url: OPTIONS_PAGE })
}
})
}
chrome.browserAction.onClicked.addListener(openOrFocusOptionsPage)
複製程式碼
因為 background.js 呼叫了 chrome.tabs 相關的 API,所以還需要在 permissions
選項中增加 tabs
的許可權宣告。storage
許可權是 Firebase 用來儲存登入狀態的,不加這個許可權則每次開啟瀏覽器外掛都處於非登入狀態。
最後,還有一件事情要做,當執行 npm run build
時,我們需要把 public 目錄下的所有檔案一同拷貝到 dist 目錄中,我們在 Webpack 中使用 copy-webpack-plugin
實現。
// webpack.config.js
const CopyWebpackPlugin = require('copy-webpack-plugin')
module.exports = {
...
plugins: [
...
new CopyWebpackPlugin([
{ from: './public', to: '' }
])
...
}
複製程式碼
使用 Firebase Auth 實現註冊登入
再總結一下 Firebase 使用者認證相關 API 的使用,看官方文件也行。示例程式碼在 chrome_ext/src/js/components/AuthBox.tsx
中。
首先取到 firebaseAuth 物件:
// chrome_ext/src/js/firebase/index.ts
const firebase = require('firebase/app')
require('firebase/auth')
firebase.initializeApp(firebaseConfig)
const firebaseAuth = firebase.auth()
複製程式碼
用郵箱密碼註冊:
firebaseAuth.createUserWithEmailAndPassword(email, password)
複製程式碼
用郵箱密碼登入:
firebaseAuth.signInWithEmailAndPassword(email, password)
複製程式碼
登出:
firebaseAuth.signOut()
複製程式碼
監聽使用者登入登出狀態的變化 (如果登入成功,在回撥中得到 user 物件,否則 user 為 null,登出後 user 也為 null):
firebaseAuth.onAuthStateChanged((user: any) => {
this.setState({user, loading: false, message: ''})
})
複製程式碼
登入後如果發現使用者的郵箱未驗證,則要求驗證郵箱 (取決於你自己的需求):
user.sendEmailVerification()
複製程式碼
重置密碼:
firebaseAuth.sendPasswordResetEmail(email)
複製程式碼
使用 Firestore 存取資料
Firestore 是包含在 Firebase 元件中的新的實時資料庫,是 NoSQL 的一種,和 MongoDB 類似,也有 collection 和 document 的概念,collection 類似關係型資料庫中的表,而 document 相當於表中的一條記錄。但 Firestore 有一點和 MongoDB 有一樣,Firestore 的 document 可以巢狀子 collection (但應該有巢狀的層級限制。)
資料庫無非是增刪改查,那就讓我們看一下如何對 Firestore 進行 CRUD。示例程式碼主要在 chrome_ext/src/js/components/IssueReport.tsx
和 TotalReport.tsx
中。
(需要注意的是,Firestore 並沒有使用 RESTful API。)
首先取到 firebaseDb 物件:
const firebase = require('firebase/app')
require('firebase/firestore')
firebase.initializeApp(firebaseConfig)
const firebaseDb = firebase.firestore()
複製程式碼
建立一個 document
document 只能從屬於 collection,但並不需要先建立一個 collection。如果你往某個名字的 collection 中新增第一個 document,那麼此 colletion 會被自動建立;如果某個 collection 中的所有 document 被刪光了,這個 collection 會被自動刪除。所以並沒有建立和刪除 collection 的 API。
所以建立 document 之前,我們先要指定 collection,我們用 firebaseDb.collection(collection_name)
來得到相應的 collection 引用,它的型別是 CollectionRef,在 collection 引用物件上呼叫 add()
方法來建立從屬於此 collection 的 document。示例:
firebaseDb.collection('users')
.add({
name: 'spark',
gender: 'male'
})
.then((docRef: DocumentRef) => console.log(docRef.id)) // docRef.gender ? undefined
.catch(err => console.log(err))
複製程式碼
add()
方法的返回值是一個 Promise<DocumentRef>
,DocumentRef 是 document 的引用,並不直接包含此 document 相應的資料,比如它並沒有一個 gender
的屬性,它只包含 id
屬性。
有了 id 以後,我們之後就可以通過 firebaseDb.collection('users').doc(id)
來取得相應的 document 的引用 (當然,在這裡是多此一舉,因為上面的返回值就已經是 document ref 了)。
另外,你可能有疑惑,add()
方法為什麼只返回 document ref,而不像 RESTful API,返回它的整個物件呢,那我要去訪問 name 和 gender 屬性的值怎麼辦?
我想是因為在 add()
中的值都是已知的,我們所缺的也就僅僅是 id,所以最後返回值只包括了 id。所以可以看到後面在呼叫 set()
和 update()
方法時,返回值是 void,連 id 都省了,因為 id 都已經是已知的了。
用 add()
方法建立的 document,其 id 是由 Firestore 產生的,是一長串沒有規律的字串,類似 UUID (就像是 MongoDB 中的 ObjectID)。如果我們想使用我們指定的 id 呢。比如這裡我們想在 users collection 中建立一個 id 為 spark 的 user。
首先,我們用 firebaseDb.collection('users').doc('spark')
來得到 document 引用 (這個 document 實際存不存在並沒有關係),然後,我們在 document ref 物件上呼叫 set()
方法填充值。
firebaseDb.collection('users')
.doc('spark')
.set({
name: 'spark',
gender: 'male'
})
.then(() => console.log('add successful'))
.catch(err => console.log(err))
複製程式碼
正如前面所說,在呼叫 set()
方法建立 document 時,id 和值都是我們已知的,所以並不需要返回值,只需要知道成功或失敗即可。
現在我們已經瞭解了兩種資料型別:CollectionRef 和 DocumentRef,前者是對某個 collection 的引用,而後者是對某個 document 的引用。
你可能還是好奇,那到底怎麼才能拿到一個完整的 document 資料呢?彆著急,我們會在查詢一節講到。
刪除一個 document
刪除就比較簡單了,首先拿到 document 引用,然後呼叫 delete()
方法即可,返回值為 Promise<void>
。
示例,刪除剛才建立的 spark 使用者:
firebaseDb.collection('users')
.doc('spark')
.delete()
.then(() => console.log('delete successful'))
.catch(err => console.log(err))
複製程式碼
那如果在客戶端我想同時刪除多個 document,或者刪除整個 collection 呢,很遺憾的或者說很奇芭的一點是,Firestore 並不支援,除非你在控制檯操作或通過 Admin API 刪除。我們只能通過迴圈遍歷,依次取得要刪除的 document 引用物件,呼叫它們的 delete()
方法,略蛋疼,可能是出於資料安全的考慮吧,畢竟這是在客戶端直接運算元據庫。
修改一個 document
類似 set()
和 delete()
方法,先取得 document 的引用,然後呼叫 update()
方法,返回值是 Promise<void>
。
firebaseDb.collection('users')
.doc('spark')
.update({
name: 'spark001',
})
.then(() => console.log('update successful'))
.catch(err => console.log(err))
複製程式碼
update()
中沒有指定的欄位,其值保持原樣。
同時修改多個 document?還是別想了吧。
查詢 document
查詢是重頭戲。
建立 / 刪除 / 修改 都只能對一個 document 進行操作,查詢可不行。
查詢一個 document
首先,回到前面的問題,當我們通過 firebaseDb.collection(colletion_name).doc(id)
拿到一個 document 的引用後,怎麼取得其中真正的資料。DocumentRef 物件有一個 get()
方法,它的返回值是 Promise<DocumentSnapshot>
,再對 DocumentSnapshot 物件呼叫 data()
方法,才能真正訪問到其中的資料,data()
方法的返回值是 DocumentData 型別物件。但是訪問之前,我們還要判斷一下這個 document 是不是真的存的,因為我們可以引用的是一個不存在的,空的 document。
示例程式碼:
firebaseDb.collection('users')
.doc('spark')
.get()
.then((docSnapshot: DocumentSnapshot) => {
if (docSnapshot.exists) {
console.log('user:': docSnapshot.data()) // {name: 'spark001', gender: 'male'}
} else {
console.log('no this user')
}
})
.catch((err: Error) => console.log(err))
複製程式碼
查詢多個 document
如果我們查詢的是多個 document 呢,比如我們返回某個集合中所有的 document,或者是符合某些條件的 document,比如在 users 表中查詢 gender 為 male 的使用者。
示例,返回集合中的所有 document:
firebaseDb.collection('users')
.get()
複製程式碼
返回集合中符合條件的 document:
firebaseDb.collection('users')
.where('gender', '==', 'male')
.get()
複製程式碼
對 CollectionRef 呼叫 where()
查詢條件方法,將得到 Query 物件。對 CollectionRef 和 Query 物件呼叫 get()
方法,都將得到 Promise<QuerySnapshot>
物件。
QuerySnapshot 物件是 DocumentSnapshot 的集合,它有一個 forEach 方法用來遍歷,從而可以依次取得其它的 DocumentSnapshot 物件,再從 DocumentSnapshot 中取得 DocumentData 物件,我們真正需要的資料。
來看一個本專案中實際的例子:
// TotalReport.tsx
loadUsers = (domain: string) => {
return firebaseDb.collection(dbCollections.DOMAINS)
.doc(domain)
.collection(dbCollections.USERS)
.orderBy('username')
.get()
.then((querySnapshot: any) => {
let users: IProfile[] = [DEF_USER]
querySnapshot.forEach((snapshot: any)=>users.push(snapshot.data()))
this.setState({users})
this.autoChooseUser(users)
})
}
複製程式碼
實時查詢
前面我們用 get()
方法實現了一次性的查詢,而 Firestore 是一個實時資料庫,這意味著,我們可以監聽資料庫的變化,如果有符合條件的資料發生變化,我們將接收到變化通知,從而實現實時的查詢。
Firestore 使用 onSnapshot()
方法來監聽資料變化,可以作用在 DocumentRef,CollectionRef,Query 物件上。它接收回撥函式作為引數,回撥函式的引數型別和 get()
方法返回的 Promise 中包含的資料型別相同,分別是 DocumentSnapshot 和 QuerySnapshot。
onSnapshot()
呼叫以後,我們需要在合適的時候取消監聽,否則造成資源浪費。onSnapshot()
的返回值是一個函式,呼叫這個函式就可以取消監聽。
來自本專案的真實示例程式碼:
// TotalReport.tsx
componentWillUnmount() {
this.unsubscribe && this.unsubscribe()
}
queryTimeLogs = () => {
this.unsubscribe = query.onSnapshot((snapshot: any) => {
let timeLogs: ITimeNote[] = []
snapshot.forEach((s: any) => timeLogs.push(s.data()))
this.aggregateTimeLogs(timeLogs)
}, (err: any) => {
this.setState({message: CommonUtil.formatFirebaseError(err), loading: false})
})
...
}
複製程式碼
最後,如果你覺得這個例子對於理解 Firebase 的使用過於複雜的話,可以看這個例子:cf-firebase-demo,用 Firebase 實現的 TodoList,核心程式碼不到一百行。