此文粗略记录用 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,核心代码不到一百行。