此文粗略記錄用 React+TypeScript+Firebase 實現一個用來統計 Gitlab Spent Time 的 Chrome Extension 的過程。css
內容包括:html
項目地址: GitHubnode
當初想寫這個擴展的動機,是源於咱們公司將項目管理平臺從 Redmine 切換到了 GitLab,GitLab 相比 Redmine 確實更加 fashion,但它有一個咱們很須要的功能卻不完善,就是時間統計報表,咱們須要爲每個 issue 記錄所花費的時間,在 Redmine 上,PM 能夠方便地查詢和生成每一個人在某個時間段花費的時間報表,但 GitLab 不行,所以 PM 很頭疼,因而想到寫這個插件來減輕他們的痛苦。react
咱們試過一些第三方工具,好比 gtt,但這些工具一是耗時很長 (都是經過 GitLab API 先遍歷 projects,再遍歷 project 下的 issues,最後遍歷 issue 下的 time notes),二是對於 PM 來講,使用太複雜 (gtt 是一個命令行工具,並且參數衆多)。webpack
固然,其實最後 PM 們也沒用上我這個工具,由於後來發現了更簡單的辦法,經過查閱 GitLab 的源碼,發現實際上在 GitLab 的 database 中,是有一個叫 timelogs 的表,直接存放了 time notes,可是很遺憾 GitLab 並無開聽任何 API 去訪問這個表,因而咱們寫了一個 rails 的項目,直接去訪問 GitLab 的 database 來生成報表 (這個項目還在內部完善中)。git
雖然如此,我仍是經過這個項目學習到了不少,學習到了 TypeScript 的使用,Firebase 的使用,加深了對 Webpack 的理解。我會把它做爲個人 side project 繼續優化。es6
(用星號隱去了一些真實信息)github
在每個 issue page 爲每個 issue 生成實時的 spent time 報表web
爲全部項目和用戶生成實時的 spent time dashboardchrome
一個快速 log 今天的 spent time 的按鈕,用來解決時區問題,若是服務器佈署在另外一個相距較遠的時區
由於這個擴展推薦在各個公司內部本身使用,因此並無發佈到 Chrome Store。
若是你想嘗試或有這個需求,能夠看這個文檔:
咱們用 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。
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_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 用戶認證相關 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 是包含在 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 只能從屬於 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 引用,而後調用 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()
方法,略蛋疼,多是出於數據安全的考慮吧,畢竟這是在客戶端直接操做數據庫。
相似 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 進行操做,查詢可不行。
首先,回到前面的問題,當咱們經過 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,好比在 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,核心代碼不到一百行。