- 原文地址:Accepting Payments with Stripe, Vue.js, and Flask
- 原文作者:Michael Herman
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:Mcskiller
- 校對者:kasheemlew
在本教程中,我們將會開發一個使用 Stripe(處理付款訂單),Vue.js(客戶端應用)以及 Flask(服務端 API)的 web 應用來售賣書籍。
這是一個進階教程。我們預設您已經基本掌握了 Vue.js 和 Flask。如果你還沒有了解過它們,請檢視下面的連結以瞭解更多:
最終效果:
主要依賴:
- Vue v2.5.2
- Vue CLI v2.9.3
- Node v10.3.0
- NPM v6.1.0
- Flask v1.0.2
- Python v3.6.5
目錄
目的
在本教程結束的時候,你能夠...
- 獲得一個現有的 CRUD 應用,由 Vue 和 Flask 驅動
- 建立一個訂單結算元件
- 使用原生 JavaScript 驗證一個表單
- 使用 Stripe 驗證信用卡資訊
- 通過 Stripe API 處理付款
專案安裝
Clone flask-vue-crud 倉庫,然後在 master 分支找到 v1 標籤:
$ git clone https://github.com/testdrivenio/flask-vue-crud --branch v1 --single-branch
$ cd flask-vue-crud
$ git checkout tags/v1 -b master
複製程式碼
搭建並啟用一個虛擬環境,然後執行 Flask 應用:
$ cd server
$ python3.6 -m venv env
$ source env/bin/activate
(env)$ pip install -r requirements.txt
(env)$ python app.py
複製程式碼
上述搭建環境的命令可能因作業系統和執行環境而異。
用瀏覽器訪問 http://localhost:5000/ping。你會看到:
"pong!"
複製程式碼
然後,安裝依賴並在另一個終端中執行 Vue 應用:
$ cd client
$ npm install
$ npm run dev
複製程式碼
轉到 http://localhost:8080。確保 CRUD 基本功能正常工作:
想學習如何構建這個專案?檢視 用 Flask 和 Vue.js 開發一個單頁面應用 文章。
我們要做什麼?
我們的目標是構建一個允許終端使用者購買書籍的 web 應用。
客戶端 Vue 應用將會顯示出可供購買的書籍並記錄付款資訊,然後從 Stripe 獲得 token,最後傳送 token 和付款資訊到服務端。
然後 Flask 應用獲取到這些資訊,並把它們都打包傳送到 Stripe 去處理。
最後,我們會用到一個客戶端 Stripe 庫 Stripe.js,它會生成一個專有 token 來建立賬單,然後使用服務端 Python Stripe 庫和 Stripe API 互動。
和之前的 教程 一樣,我們會簡化步驟,你應該自己處理產生的其他問題,這樣也會加強你的理解。
CRUD 書籍
首先,讓我們將購買價格新增到伺服器端的現有書籍列表中,然後在客戶端上更新相應的 CRUD 函式 GET,POST 和 PUT。
GET
首先在 server/app.py 中新增 price
到 BOOKS
列表的每一個字典元素中:
BOOKS = [
{
'id': uuid.uuid4().hex,
'title': 'On the Road',
'author': 'Jack Kerouac',
'read': True,
'price': '19.99'
},
{
'id': uuid.uuid4().hex,
'title': 'Harry Potter and the Philosopher\'s Stone',
'author': 'J. K. Rowling',
'read': False,
'price': '9.99'
},
{
'id': uuid.uuid4().hex,
'title': 'Green Eggs and Ham',
'author': 'Dr. Seuss',
'read': True,
'price': '3.99'
}
]
複製程式碼
然後,在 Books
元件 client/src/components/Books.vue 中更新表格以顯示購買價格。
<table class="table table-hover">
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Author</th>
<th scope="col">Read?</th>
<th scope="col">Purchase Price</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(book, index) in books" :key="index">
<td>{{ book.title }}</td>
<td>{{ book.author }}</td>
<td>
<span v-if="book.read">Yes</span>
<span v-else>No</span>
</td>
<td>${{ book.price }}</td>
<td>
<button type="button"
class="btn btn-warning btn-sm"
v-b-modal.book-update-modal
@click="editBook(book)">
Update
</button>
<button type="button"
class="btn btn-danger btn-sm"
@click="onDeleteBook(book)">
Delete
</button>
</td>
</tr>
</tbody>
</table>
複製程式碼
你現在應該會看到:
POST
新增一個新 b-form-group
到 addBookModal
中,在 Author 和 read 的 b-form-group
類之間:
<b-form-group id="form-price-group"
label="Purchase price:"
label-for="form-price-input">
<b-form-input id="form-price-input"
type="number"
v-model="addBookForm.price"
required
placeholder="Enter price">
</b-form-input>
</b-form-group>
複製程式碼
這個模態現在看起來應該是這樣:
<!-- add book modal -->
<b-modal ref="addBookModal"
id="book-modal"
title="Add a new book"
hide-footer>
<b-form @submit="onSubmit" @reset="onReset" class="w-100">
<b-form-group id="form-title-group"
label="Title:"
label-for="form-title-input">
<b-form-input id="form-title-input"
type="text"
v-model="addBookForm.title"
required
placeholder="Enter title">
</b-form-input>
</b-form-group>
<b-form-group id="form-author-group"
label="Author:"
label-for="form-author-input">
<b-form-input id="form-author-input"
type="text"
v-model="addBookForm.author"
required
placeholder="Enter author">
</b-form-input>
</b-form-group>
<b-form-group id="form-price-group"
label="Purchase price:"
label-for="form-price-input">
<b-form-input id="form-price-input"
type="number"
v-model="addBookForm.price"
required
placeholder="Enter price">
</b-form-input>
</b-form-group>
<b-form-group id="form-read-group">
<b-form-checkbox-group v-model="addBookForm.read" id="form-checks">
<b-form-checkbox value="true">Read?</b-form-checkbox>
</b-form-checkbox-group>
</b-form-group>
<b-button type="submit" variant="primary">Submit</b-button>
<b-button type="reset" variant="danger">Reset</b-button>
</b-form>
</b-modal>
複製程式碼
然後,新增 price
到 addBookForm
屬性中:
addBookForm: {
title: '',
author: '',
read: [],
price: '',
},
複製程式碼
addBookForm
現在和表單的輸入值進行了繫結。想想這意味著什麼。當 addBookForm
被更新時,表單的輸入值也會被更新,反之亦然。以下是 vue-devtools 瀏覽器擴充套件的示例。
將 price
新增到 onSubmit
方法的 payload
中,像這樣:
onSubmit(evt) {
evt.preventDefault();
this.$refs.addBookModal.hide();
let read = false;
if (this.addBookForm.read[0]) read = true;
const payload = {
title: this.addBookForm.title,
author: this.addBookForm.author,
read, // property shorthand
price: this.addBookForm.price,
};
this.addBook(payload);
this.initForm();
},
複製程式碼
更新 initForm
函式,在使用者提交表單點選 "重置" 按鈕後清除已有的值:
initForm() {
this.addBookForm.title = '';
this.addBookForm.author = '';
this.addBookForm.read = [];
this.addBookForm.price = '';
this.editForm.id = '';
this.editForm.title = '';
this.editForm.author = '';
this.editForm.read = [];
},
複製程式碼
最後,更新 server/app.py 中的路由:
@app.route('/books', methods=['GET', 'POST'])
def all_books():
response_object = {'status': 'success'}
if request.method == 'POST':
post_data = request.get_json()
BOOKS.append({
'id': uuid.uuid4().hex,
'title': post_data.get('title'),
'author': post_data.get('author'),
'read': post_data.get('read'),
'price': post_data.get('price')
})
response_object['message'] = 'Book added!'
else:
response_object['books'] = BOOKS
return jsonify(response_object)
複製程式碼
趕緊測試一下吧!
不要忘了處理客戶端和服務端的錯誤!
PUT
同樣的操作,不過這次是編輯書籍,該你自己動手了:
- 新增一個新輸入表單到模態中
- 更新屬性中的
editForm
部分 - 新增
price
到onSubmitUpdate
方法的payload
中 - 更新
initForm
- 更新服務端路由
需要幫助嗎?重新看看前面的章節。或者你可以從 flask-vue-crud 倉庫獲得原始碼。
訂單頁面
接下來,讓我們新增一個訂單頁面,使用者可以在其中輸入信用卡資訊來購買圖書。
TODO:新增圖片
新增一個購買按鈕
首先給 Books
元件新增一個“購買”按鈕,就在“刪除”按鈕的下方:
<td>
<button type="button"
class="btn btn-warning btn-sm"
v-b-modal.book-update-modal
@click="editBook(book)">
Update
</button>
<button type="button"
class="btn btn-danger btn-sm"
@click="onDeleteBook(book)">
Delete
</button>
<router-link :to="`/order/${book.id}`"
class="btn btn-primary btn-sm">
Purchase
</router-link>
</td>
複製程式碼
這裡,我們使用了 router-link 元件來生成一個連線到 client/src/router/index.js 中的路由的錨點,我們馬上就會用到它。
建立模板
新增一個叫做 Order.vue 的新元件檔案到 client/src/components:
<template>
<div class="container">
<div class="row">
<div class="col-sm-10">
<h1>Ready to buy?</h1>
<hr>
<router-link to="/" class="btn btn-primary">
Back Home
</router-link>
<br><br><br>
<div class="row">
<div class="col-sm-6">
<div>
<h4>You are buying:</h4>
<ul>
<li>Book Title: <em>Book Title</em></li>
<li>Amount: <em>$Book Price</em></li>
</ul>
</div>
<div>
<h4>Use this info for testing:</h4>
<ul>
<li>Card Number: 4242424242424242</li>
<li>CVC Code: any three digits</li>
<li>Expiration: any date in the future</li>
</ul>
</div>
</div>
<div class="col-sm-6">
<h3>One time payment</h3>
<br>
<form>
<div class="form-group">
<label>Credit Card Info</label>
<input type="text"
class="form-control"
placeholder="XXXXXXXXXXXXXXXX"
required>
</div>
<div class="form-group">
<input type="text"
class="form-control"
placeholder="CVC"
required>
</div>
<div class="form-group">
<label>Card Expiration Date</label>
<input type="text"
class="form-control"
placeholder="MM/YY"
required>
</div>
<button class="btn btn-primary btn-block">Submit</button>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
複製程式碼
你可能會想收集買家的聯絡資訊,比如姓名,郵件地址,送貨地址等等。這就得靠你自己了。
新增路由
client/src/router/index.js:
import Vue from 'vue';
import Router from 'vue-router';
import Ping from '@/components/Ping';
import Books from '@/components/Books';
import Order from '@/components/Order';
Vue.use(Router);
export default new Router({
routes: [
{
path: '/',
name: 'Books',
component: Books,
},
{
path: '/order/:id',
name: 'Order',
component: Order,
},
{
path: '/ping',
name: 'Ping',
component: Ping,
},
],
mode: 'hash',
});
複製程式碼
測試一下。
獲取產品資訊
接下來,讓我們在訂單頁面 上更新書名和金額的佔位符:
回到服務端並更新以下路由介面:
@app.route('/books/<book_id>', methods=['GET', 'PUT', 'DELETE'])
def single_book(book_id):
response_object = {'status': 'success'}
if request.method == 'GET':
# TODO: refactor to a lambda and filter
return_book = ''
for book in BOOKS:
if book['id'] == book_id:
return_book = book
response_object['book'] = return_book
if request.method == 'PUT':
post_data = request.get_json()
remove_book(book_id)
BOOKS.append({
'id': uuid.uuid4().hex,
'title': post_data.get('title'),
'author': post_data.get('author'),
'read': post_data.get('read'),
'price': post_data.get('price')
})
response_object['message'] = 'Book updated!'
if request.method == 'DELETE':
remove_book(book_id)
response_object['message'] = 'Book removed!'
return jsonify(response_object)
複製程式碼
我們可以在 script
中使用這個路由向訂單頁面新增書籍資訊:
<script>
import axios from 'axios';
export default {
data() {
return {
book: {
title: '',
author: '',
read: [],
price: '',
},
};
},
methods: {
getBook() {
const path = `http://localhost:5000/books/${this.$route.params.id}`;
axios.get(path)
.then((res) => {
this.book = res.data.book;
})
.catch((error) => {
// eslint-disable-next-line
console.error(error);
});
},
},
created() {
this.getBook();
},
};
</script>
複製程式碼
轉到生產環境?你將需要使用環境變數來動態設定基本伺服器端 URL(現在 URL 為
http://localhost:5000
)。檢視 文件 獲取更多資訊。
然後,更新 template 中的第一個 ul
:
<ul>
<li>Book Title: <em>{{ book.title }}</em></li>
<li>Amount: <em>${{ book.price }}</em></li>
</ul>
複製程式碼
你現在會看到:
表單驗證
讓我們設定一些基本的表單驗證。
使用 v-model
指令去 繫結 表單輸入值到屬性中:
<form>
<div class="form-group">
<label>Credit Card Info</label>
<input type="text"
class="form-control"
placeholder="XXXXXXXXXXXXXXXX"
v-model="card.number"
required>
</div>
<div class="form-group">
<input type="text"
class="form-control"
placeholder="CVC"
v-model="card.cvc"
required>
</div>
<div class="form-group">
<label>Card Expiration Date</label>
<input type="text"
class="form-control"
placeholder="MM/YY"
v-model="card.exp"
required>
</div>
<button class="btn btn-primary btn-block">Submit</button>
</form>
複製程式碼
新增 card 屬性,就像這樣:
card: {
number: '',
cvc: '',
exp: '',
},
複製程式碼
接下來,更新“提交”按鈕,以便在單擊按鈕時忽略正常的瀏覽器行為,並呼叫 validate
方法:
<button class="btn btn-primary btn-block" @click.prevent="validate">Submit</button>
複製程式碼
將陣列新增到屬性中以儲存驗證錯誤資訊:
data() {
return {
book: {
title: '',
author: '',
read: [],
price: '',
},
card: {
number: '',
cvc: '',
exp: '',
},
errors: [],
};
},
複製程式碼
就新增在表單的下方,我們能夠依次顯示所有錯誤:
<div v-show="errors">
<br>
<ol class="text-danger">
<li v-for="(error, index) in errors" :key="index">
{{ error }}
</li>
</ol>
</div>
複製程式碼
新增 validate
方法:
validate() {
this.errors = [];
let valid = true;
if (!this.card.number) {
valid = false;
this.errors.push('Card Number is required');
}
if (!this.card.cvc) {
valid = false;
this.errors.push('CVC is required');
}
if (!this.card.exp) {
valid = false;
this.errors.push('Expiration date is required');
}
if (valid) {
this.createToken();
}
},
複製程式碼
由於所有欄位都是必須填入的,而我們只是驗證了每一個欄位是否都有一個值。Stripe 將會驗證下一節你看到的信用卡資訊,所以你不必過度驗證表單資訊。也就是說,只需要保證你自己新增的其他欄位通過驗證。
最後,新增 createToken
方法:
createToken() {
// eslint-disable-next-line
console.log('The form is valid!');
},
複製程式碼
測試一下。
Stripe
如果你沒有 Stripe 賬號的話需要先註冊一個,然後再去獲取你的 測試模式 API Publishable key。
客戶端
新增 stripePublishableKey 和 stripeCheck
(用來禁用提交按鈕)到 data 中:
data() {
return {
book: {
title: '',
author: '',
read: [],
price: '',
},
card: {
number: '',
cvc: '',
exp: '',
},
errors: [],
stripePublishableKey: 'pk_test_aIh85FLcNlk7A6B26VZiNj1h',
stripeCheck: false,
};
},
複製程式碼
確保新增你自己的 Stripe key 到上述程式碼中。
同樣,如果表單有效,觸發 createToken
方法(通過 Stripe.js)驗證信用卡資訊然後返回一個錯誤資訊(如果無效)或者返回一個 token(如果有效):
createToken() {
this.stripeCheck = true;
window.Stripe.setPublishableKey(this.stripePublishableKey);
window.Stripe.createToken(this.card, (status, response) => {
if (response.error) {
this.stripeCheck = false;
this.errors.push(response.error.message);
// eslint-disable-next-line
console.error(response);
} else {
// pass
}
});
},
複製程式碼
如果沒有錯誤的話,我們就傳送 token 到伺服器,在那裡我們會完成扣費並把使用者轉回主頁:
createToken() {
this.stripeCheck = true;
window.Stripe.setPublishableKey(this.stripePublishableKey);
window.Stripe.createToken(this.card, (status, response) => {
if (response.error) {
this.stripeCheck = false;
this.errors.push(response.error.message);
// eslint-disable-next-line
console.error(response);
} else {
const payload = {
book: this.book,
token: response.id,
};
const path = 'http://localhost:5000/charge';
axios.post(path, payload)
.then(() => {
this.$router.push({ path: '/' });
})
.catch((error) => {
// eslint-disable-next-line
console.error(error);
});
}
});
},
複製程式碼
按照上述程式碼更新 createToken()
,然後新增 Stripe.js 到 client/index.html 中:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Books!</title>
</head>
<body>
<div id="app"></div>
<!-- built files will be auto injected -->
<script type="text/javascript" src="https://js.stripe.com/v2/"></script>
</body>
</html>
複製程式碼
Stripe 支援 v2 和 v3(Stripe Elements)版本的 Stripe.js。如果你對 Stripe Elements 和如何把它整合到 Vue 中感興趣,參閱以下資源:1. Stripe Elements 遷移指南 2. 整合 Stripe Elements 和 Vue.js 來建立一個自定義付款表單
現在,當 createToken
被觸發是,stripeCheck
值被更改為 true
,為了防止重複收費,我們在 stripeCheck
值為 true
時禁用“提交”按鈕:
<button class="btn btn-primary btn-block"
@click.prevent="validate"
:disabled="stripeCheck">
Submit
</button>
複製程式碼
測試一下 Stripe 驗證的無效反饋:
- 信用卡卡號
- 安全碼
- 有效日期
現在,讓我們開始設定服務端路由。
服務端
安裝 Stripe 庫:
$ pip install stripe==1.82.1
複製程式碼
新增路由介面:
@app.route('/charge', methods=['POST'])
def create_charge():
post_data = request.get_json()
amount = round(float(post_data.get('book')['price']) * 100)
stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
charge = stripe.Charge.create(
amount=amount,
currency='usd',
card=post_data.get('token'),
description=post_data.get('book')['title']
)
response_object = {
'status': 'success',
'charge': charge
}
return jsonify(response_object), 200
複製程式碼
在這裡設定書籍價格(轉換為美分),專有 token(來自客戶端的 createToken
方法),以及書名,然後我們利用 API Secret key 生成一個新的 Stripe 賬單。
瞭解更多建立賬單的資訊,參考官方 API 文件。
Update the imports:
import os
import uuid
import stripe
from flask import Flask, jsonify, request
from flask_cors import CORS
複製程式碼
獲取 測試模式 API Secret key:
把它設定成一個環境變數:
$ export STRIPE_SECRET_KEY=sk_test_io02FXL17hrn2TNvffanlMSy
複製程式碼
確保使用的是你自己的 Stripe key!
測試一下吧!
在 Stripe Dashboard 中你應該會看到購買記錄:
你可能還想建立 顧客,而不僅僅是建立賬單。這樣一來有諸多優點。你能同時購買多個物品,以便跟蹤客戶購買記錄。你可以向經常購買的使用者提供優惠,或者向許久未購買的使用者聯絡,還有許多用處這裡就不做介紹了。它還可以用來防止欺詐。參考以下 Flask 專案 來看看如何新增客戶。
訂單完成頁面
比起把買家直接轉回主頁,我們更應該把他們重定向到一個訂單完成頁面,以感謝他們的購買。
新增一個叫 OrderComplete.vue 的新元件檔案到 “client/src/components” 中:
<template>
<div class="container">
<div class="row">
<div class="col-sm-10">
<h1>Thanks for purchasing!</h1>
<hr><br>
<router-link to="/" class="btn btn-primary btn-sm">Back Home</router-link>
</div>
</div>
</div>
</template>
複製程式碼
更新路由:
import Vue from 'vue';
import Router from 'vue-router';
import Ping from '@/components/Ping';
import Books from '@/components/Books';
import Order from '@/components/Order';
import OrderComplete from '@/components/OrderComplete';
Vue.use(Router);
export default new Router({
routes: [
{
path: '/',
name: 'Books',
component: Books,
},
{
path: '/order/:id',
name: 'Order',
component: Order,
},
{
path: '/complete',
name: 'OrderComplete',
component: OrderComplete,
},
{
path: '/ping',
name: 'Ping',
component: Ping,
},
],
mode: 'hash',
});
複製程式碼
在 createToken
方法中更新重定向:
createToken() {
this.stripeCheck = true;
window.Stripe.setPublishableKey(this.stripePublishableKey);
window.Stripe.createToken(this.card, (status, response) => {
if (response.error) {
this.stripeCheck = false;
this.errors.push(response.error.message);
// eslint-disable-next-line
console.error(response);
} else {
const payload = {
book: this.book,
token: response.id,
};
const path = 'http://localhost:5000/charge';
axios.post(path, payload)
.then(() => {
this.$router.push({ path: '/complete' });
})
.catch((error) => {
// eslint-disable-next-line
console.error(error);
});
}
});
},
複製程式碼
最後,你還可以在訂單完成頁面顯示客戶剛剛購買的書籍的(標題,金額,等等)。
獲取唯一的賬單 ID 然後傳遞給 path
:
createToken() {
this.stripeCheck = true;
window.Stripe.setPublishableKey(this.stripePublishableKey);
window.Stripe.createToken(this.card, (status, response) => {
if (response.error) {
this.stripeCheck = false;
this.errors.push(response.error.message);
// eslint-disable-next-line
console.error(response);
} else {
const payload = {
book: this.book,
token: response.id,
};
const path = 'http://localhost:5000/charge';
axios.post(path, payload)
.then((res) => {
// updates
this.$router.push({ path: `/complete/${res.data.charge.id}` });
})
.catch((error) => {
// eslint-disable-next-line
console.error(error);
});
}
});
},
複製程式碼
更新客戶端路由:
{
path: '/complete/:id',
name: 'OrderComplete',
component: OrderComplete,
},
複製程式碼
然後,在 OrderComplete.vue 中,從 URL 中獲取賬單 ID 併傳送到服務端:
<script>
import axios from 'axios';
export default {
data() {
return {
book: '',
};
},
methods: {
getChargeInfo() {
const path = `http://localhost:5000/charge/${this.$route.params.id}`;
axios.get(path)
.then((res) => {
this.book = res.data.charge.description;
})
.catch((error) => {
// eslint-disable-next-line
console.error(error);
});
},
},
created() {
this.getChargeInfo();
},
};
</script>
複製程式碼
在伺服器上配置新路由來 檢索 賬單:
@app.route('/charge/<charge_id>')
def get_charge(charge_id):
stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
response_object = {
'status': 'success',
'charge': stripe.Charge.retrieve(charge_id)
}
return jsonify(response_object), 200
複製程式碼
最後,在 template 中更新 <h1></h1>
:
<h1>Thanks for purchasing - {{ this.book }}!</h1>
複製程式碼
最後一次測試。
總結
完成了!一定要從最開始進行閱讀。你可以在 GitHub 中的 flask-vue-crud 倉庫找到原始碼。
想挑戰更多?
- 新增客戶端和服務端的單元和整合測試。
- 建立一個購物車以方便顧客能夠一次購買多本書。
- 使用 Postgres 來儲存書籍和訂單。
- 使用 Docker 整合 Vue 和 Flask(以及 Postgres,如果你加入了的話)來簡化開發工作流程。
- 給書籍新增圖片來建立一個更好的產品頁面。
- 獲取 email 然後傳送 email 確認郵件(查閱 使用 Flask、Redis Queue 和 Amazon SES 傳送確認電子郵件)。
- 部署客戶端靜態檔案到 AWS S3 然後部署服務端應用到一臺 EC2 例項。
- 投入生產環境?思考一個最好的更新 Stripe key 的方法,讓它們基於環境動態更新。
- 建立一個分離元件來退訂。
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。