使用 GraphQL 和 Chart.js 构建实时权力的游戏投票应用程序
TL;DR🕑
长版
⚙️ 我是如何建造它的 ⚙️
🔧 后端 🔧
✨ 前端 ✨
整合起来🤝
维拉·维兹
TL;DR🕑
长版
我一直想做一个投票应用,因为嘿——它们太酷了! 我读了一篇 关于如何使用 chart.js 和 GraphQL在 JavaScript 中构建图表的 文章,其中用到了一个很棒的工具,叫做 graphql2chartjs 。时机真是太棒了,《权力的游戏》临冬城之战还有几天就要开始了,所以我决定去体验一下大家认为谁会在这一集里漫漫长夜里被淘汰。
我发了这条推文并等待⏳
该应用在节目播出前就 获得了令人震惊的 10,000 张投票
更不用说,超过 50% 的选票都投给了灰虫子 #RIPGreyWorm
太吓人了!我重置了投票数,这样你就能体验一下这个应用和它的功能了。
⚙️ 我是如何建造它的 ⚙️
该应用程序具有: 📊 前端的 Vue.js + Chartjs 🖥️ 😈 后端的 Hasura + Apollo GraphQL ⚡ 🚀 部署在 Netlify 上🔥
🔧 后端 🔧
我使用 Hasura 及其一键式 Heroku 部署 来设置我的后端。Hasura 通过 PostgreSQL 数据库为我们提供了实时 GraphQL 功能。接下来我们需要定义一个模式,在API 控制台的 “数据” 部分,我们需要创建一个 characters
包含以下列的表……
- id
保存一个整数值,是主键并且自动递增 - name
保存一个文本值 - votes
保存一个整数值并将默认值设置为 0
一旦设置了模式,您必须在 API 控制台的 数据部分手动输入角色名称。
我们现在已经完成了后端工作。
✨ 前端 ✨
正如我上面所说,我的前端是用 Vue.js 做的,我们必须先安装它才能继续下一步,而要做到这一点,我们需要在系统上安装 Node.js。 安装完 Node.js 后,输入以下命令安装 vue.cli npm i -g @vue/cli
。要设置一个新的 Vue 项目,我们输入以下命令 vue create myapp
,将 myapp 替换为任何你想给这个应用起的别致名字,然后在提示选择预设时点击 “default” 。初始化完成后,你的文件夹结构应该类似于下图。
应用初始化完成后, cd <myapp>
输入 npm run serve
运行应用。命令行会显示应用托管的本地地址,打开浏览器并访问该地址。应该就是你看到的内容。
整合起来🤝
此时,我们在前端有一个基本的 Vue 应用程序,并且使用 Hasura 的后端已初始化。我们的目标是创建一个应用程序来可视化《权力的游戏》角色的死亡投票,因此我们继续使用以下命令安装可视化工具 chart.js npm install vue-chartjs chart.js --save
。我们还安装了 graphql2chartjs,该工具可帮助我们读取 graphql 数据并在图表中使用,为此,我们运行以下命令 npm install --save graphql2chartjs
。
我们需要将一些文件导入到 main.js 文件中。导入完成后,你的 main.js 应该如下所示:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
import { ApolloClient } from 'apollo-client'
import { WebSocketLink } from 'apollo-link-ws' ;
import { HttpLink } from 'apollo-link-http' ;
import { split } from 'apollo-link' ;
import { getMainDefinition } from 'apollo-utilities' ;
import { InMemoryCache } from 'apollo-cache-inmemory' ;
import Vue from 'vue'
import VueApollo from 'vue-apollo'
import App from './App'
Vue . config . productionTip = false
const httpLink = new HttpLink ( {
// You should use an absolute URL here
uri : 'https://<your-app-name>/v1alpha1/graphql'
} ) ;
const wsLink = new WebSocketLink ( {
uri : "wss://<your-app-name>.herokuapp.com/v1alpha1/graphql" ,
options : {
reconnect : true
}
} ) ;
const link = split (
// split based on operation type
( { query } ) => {
const { kind, operation } = getMainDefinition ( query ) ;
return kind === 'OperationDefinition' && operation === 'subscription' ;
} ,
wsLink ,
httpLink ,
) ;
const apolloClient = new ApolloClient ( {
link,
cache : new InMemoryCache ( ) ,
connectToDevTools : true
} )
Vue . use ( VueApollo )
const apolloProvider = new VueApollo ( {
defaultClient : apolloClient ,
defaultOptions : {
$loadingKey : 'loading'
}
} )
/* eslint-disable no-new */
new Vue ( {
el : '#app' ,
apolloProvider,
render : h => h ( App )
} )
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
import { ApolloClient } from 'apollo-client'
import { WebSocketLink } from 'apollo-link-ws' ;
import { HttpLink } from 'apollo-link-http' ;
import { split } from 'apollo-link' ;
import { getMainDefinition } from 'apollo-utilities' ;
import { InMemoryCache } from 'apollo-cache-inmemory' ;
import Vue from 'vue'
import VueApollo from 'vue-apollo'
import App from './App'
Vue . config . productionTip = false
const httpLink = new HttpLink ( {
// You should use an absolute URL here
uri : 'https://<your-app-name>/v1alpha1/graphql'
} ) ;
const wsLink = new WebSocketLink ( {
uri : "wss://<your-app-name>.herokuapp.com/v1alpha1/graphql" ,
options : {
reconnect : true
}
} ) ;
const link = split (
// split based on operation type
( { query } ) => {
const { kind, operation } = getMainDefinition ( query ) ;
return kind === 'OperationDefinition' && operation === 'subscription' ;
} ,
wsLink ,
httpLink ,
) ;
const apolloClient = new ApolloClient ( {
link,
cache : new InMemoryCache ( ) ,
connectToDevTools : true
} )
Vue . use ( VueApollo )
const apolloProvider = new VueApollo ( {
defaultClient : apolloClient ,
defaultOptions : {
$loadingKey : 'loading'
}
} )
/* eslint-disable no-new */
new Vue ( {
el : '#app' ,
apolloProvider,
render : h => h ( App )
} )
我在下面关于 GraphQL 中的查询和变异的两篇文章中解释了很多导入的包……
由于图表将实时显示数据,我们将使用 订阅功能 ,现在就来介绍一下。像往常一样,我们需要注意一些事项。在 第 16 行和 第 20 行,你需要粘贴应用程序的名称,以便 Apollo 可以帮助你的 Vue 应用程序与 GraphQL 后端进行通信。
注意第 19 行,我们的订阅实现使用 Web 套接字来保持与服务器的持续连接,并向 UI 提供最新的更新数据。
在修改了 main.js 文件之后,在 src 中,我们必须创建一个名为 Constants 的文件夹,并在其中创建一个名为 graphql.js 的文件。在该文件中,我们需要 gql
通过粘贴 import gql from graphql-tag;
到文件顶部来进行导入。
graphql.js 文件 让我们拥有一个通用文件来保存所有查询、变更和订阅。这样, 当我们需要时,可以轻松地将它们导出到 App.vue中。
你的 graphql.js 文件应该看起来像这样...
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
import gql from 'graphql-tag'
export const ADD_VOTE_MUTATION = gql `
mutation updateVotes($id: Int!) {
update_characters(where: {id: {_eq: $id}},
_inc: {votes: 1}) {
affected_rows
}
}
` ;
export const ALL_CHAR_QUERY = gql `
query characters {
characters(order_by: {id: asc}) {
id
name
}
}
` ;
export const ALL_VOTES_SUBSCRIPTION = gql `
subscription allVotes{
CharacterDeathVotes : characters(order_by: {id: asc}) {
label: name
data: votes
}
}
` ;
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
import gql from 'graphql-tag'
export const ADD_VOTE_MUTATION = gql `
mutation updateVotes($id: Int!) {
update_characters(where: {id: {_eq: $id}},
_inc: {votes: 1}) {
affected_rows
}
}
` ;
export const ALL_CHAR_QUERY = gql `
query characters {
characters(order_by: {id: asc}) {
id
name
}
}
` ;
export const ALL_VOTES_SUBSCRIPTION = gql `
subscription allVotes{
CharacterDeathVotes : characters(order_by: {id: asc}) {
label: name
data: votes
}
}
` ;
该 ALL_VOTES_QUERY
查询获取字符表中某个条目的 name
和 id
。同样,您可以尝试其他操作,并像我一样将它们添加到文件中。同样,
然后,我们创建一个图表组件,稍后会将其导出到 App.vue 文件中。我们将其命名为 BarChart.js 。如果想要一个从 API(在本例中是 GraphQL API)获取数据的响应式图表,这是标准格式。vue-chart.js 文档 对此进行了详细介绍。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
// CommitChart.js
import { HorizontalBar , mixins } from 'vue-chartjs'
const { reactiveProp } = mixins
export default {
extends : HorizontalBar ,
mixins : [ reactiveProp ] ,
props : {
chartData : {
type : Object ,
default : null
} ,
options : {
type : Object ,
default : null
}
} ,
mounted ( ) {
this . renderChart ( this . chartData , this . options )
}
}
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
// CommitChart.js
import { HorizontalBar , mixins } from 'vue-chartjs'
const { reactiveProp } = mixins
export default {
extends : HorizontalBar ,
mixins : [ reactiveProp ] ,
props : {
chartData : {
type : Object ,
default : null
} ,
options : {
type : Object ,
default : null
}
} ,
mounted ( ) {
this . renderChart ( this . chartData , this . options )
}
}
现在,在您的 App.vue 文件中,您所做的更改将在以下情况下显示:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
<template >
<div id =" app" >
<div class =" container" >
<div class =" row" >
<div class =" column" >
<h2 >Who Might Die ⚔️</h2 >
</div >
<div class =" column" >
<h2
v-if =" loading"
>⚖️ Total Votes: {{totalVotes.characters_aggregate.aggregate.sum.votes}}</h2 >
</div >
</div >
<div class =" button-box" >
<div v-for =" charName of characters" v-bind:key =" charName.id" >
<button class =" button" @click =" updateVotes(charName.id)" >{{charName.name}}</button >
</div >
</div >
<div >
<div class =" chart" >
<bar-chart v-if =" loaded" :chartData =" chartData" :options =" options" :width =" 200" :height =" 300" />
</div >
</div >
</div >
</div >
</template >
<script >
import BarChart from " ./components/BarChart.js" ;
import {
ALL_CHAR_QUERY ,
ADD_VOTE_MUTATION ,
ALL_VOTES_SUBSCRIPTION ,
SUM_VOTES_SUBSCRIPTION
} from " ./constants/graphql" ;
import graphql2chartjs from " graphql2chartjs" ;
export default {
name: " app" ,
props: {
msg: String
},
data () {
return {
loaded: false ,
loading: false ,
characters: " " ,
totalVotes: 0 ,
chartData: null ,
options: {
scales: {
yAxes: [{
ticks: {
beginAtZero: true
},
gridLines: {
display: false
}
}],
xAxes: [ {
gridLines: {
display: true
}
}]
},
legend: {
display: true
},
responsive: true ,
maintainAspectRatio: true
}
};
},
components: {
BarChart
},
methods: {
updateVotes (id ) {
this .$apollo .mutate ({
mutation: ADD_VOTE_MUTATION ,
variables: {
id: id
}
});
}
},
apollo: {
characters: {
query: ALL_CHAR_QUERY
},
$subscribe: {
votes: {
query: ALL_VOTES_SUBSCRIPTION ,
result ({ data }) {
const g2c = new graphql2chartjs ();
g2c .add (data, " bar" );
this .chartData = g2c .data ;
this .loaded = true ;
// console.log(g2c.data)
}
},
sumVotes: {
query: SUM_VOTES_SUBSCRIPTION ,
result ({ data }) {
if (data) {
this .totalVotes = data;
this .loading = true ;
// console.log(data);
}
}
}
}
}
};
</script >
<style >
#app {
font-family : " Avenir" , Helvetica , Arial , sans-serif ;
-webkit-font-smoothing : antialiased ;
-moz-osx-font-smoothing : grayscale ;
color : #2c3e50 ;
margin-top : 1% ;
}
.container {
margin : 0 auto ;
}
.row {
display : flex ;
}
.column {
flex : 50% ;
text-align : center ;
}
.chart {
box-shadow : 0px 0px 20px 2px rgba (0 , 0 , 0 , 0.4 );
border-radius : 10px ;
padding-top : 15% ;
}
button {
background-color :black ;
border-radius : 10px ;
color : white ;
width : 100px ;
height : 40px ;
float : left ;
margin : 2% ;
text-align : center ;
}
.button-box {
text-align : center ;
}
</style >
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
<template >
<div id =" app" >
<div class =" container" >
<div class =" row" >
<div class =" column" >
<h2 >Who Might Die ⚔️</h2 >
</div >
<div class =" column" >
<h2
v-if =" loading"
>⚖️ Total Votes: {{totalVotes.characters_aggregate.aggregate.sum.votes}}</h2 >
</div >
</div >
<div class =" button-box" >
<div v-for =" charName of characters" v-bind:key =" charName.id" >
<button class =" button" @click =" updateVotes(charName.id)" >{{charName.name}}</button >
</div >
</div >
<div >
<div class =" chart" >
<bar-chart v-if =" loaded" :chartData =" chartData" :options =" options" :width =" 200" :height =" 300" />
</div >
</div >
</div >
</div >
</template >
<script >
import BarChart from " ./components/BarChart.js" ;
import {
ALL_CHAR_QUERY ,
ADD_VOTE_MUTATION ,
ALL_VOTES_SUBSCRIPTION ,
SUM_VOTES_SUBSCRIPTION
} from " ./constants/graphql" ;
import graphql2chartjs from " graphql2chartjs" ;
export default {
name: " app" ,
props: {
msg: String
},
data () {
return {
loaded: false ,
loading: false ,
characters: " " ,
totalVotes: 0 ,
chartData: null ,
options: {
scales: {
yAxes: [{
ticks: {
beginAtZero: true
},
gridLines: {
display: false
}
}],
xAxes: [ {
gridLines: {
display: true
}
}]
},
legend: {
display: true
},
responsive: true ,
maintainAspectRatio: true
}
};
},
components: {
BarChart
},
methods: {
updateVotes (id ) {
this .$apollo .mutate ({
mutation: ADD_VOTE_MUTATION ,
variables: {
id: id
}
});
}
},
apollo: {
characters: {
query: ALL_CHAR_QUERY
},
$subscribe: {
votes: {
query: ALL_VOTES_SUBSCRIPTION ,
result ({ data }) {
const g2c = new graphql2chartjs ();
g2c .add (data, " bar" );
this .chartData = g2c .data ;
this .loaded = true ;
// console.log(g2c.data)
}
},
sumVotes: {
query: SUM_VOTES_SUBSCRIPTION ,
result ({ data }) {
if (data) {
this .totalVotes = data;
this .loading = true ;
// console.log(data);
}
}
}
}
}
};
</script >
<style >
#app {
font-family : " Avenir" , Helvetica , Arial , sans-serif ;
-webkit-font-smoothing : antialiased ;
-moz-osx-font-smoothing : grayscale ;
color : #2c3e50 ;
margin-top : 1% ;
}
.container {
margin : 0 auto ;
}
.row {
display : flex ;
}
.column {
flex : 50% ;
text-align : center ;
}
.chart {
box-shadow : 0px 0px 20px 2px rgba (0 , 0 , 0 , 0.4 );
border-radius : 10px ;
padding-top : 15% ;
}
button {
background-color :black ;
border-radius : 10px ;
color : white ;
width : 100px ;
height : 40px ;
float : left ;
margin : 2% ;
text-align : center ;
}
.button-box {
text-align : center ;
}
</style >
在 App.vue 中有三个代码片段需要注意:
1️⃣号
<div v-for= "charName of characters" v-bind:key= "charName.id" >
<button class= "button" @ click= "updateVotes(charName.id)" >
{{charName.name}}
</button>
</div>
Enter fullscreen mode
Exit fullscreen mode
该变量 characters
存储查询结果 ALL_CHAR_QUERY
。我们使用该 v-for
指令将结果数组中的每个项目打印为按钮的标题。重要的是,我们使用该 v-bind
指令绑定角色 ID,并将其作为键来迭代结果数组中的项目(即数据库中的所有角色)。这在将每个投票绑定到特定角色时非常有用。
2️⃣号
<h2 v-if= "loading" >
⚖️ Total Votes: {{totalVotes.characters_aggregate.aggregate.sum.votes}}
</h2>
Enter fullscreen mode
Exit fullscreen mode
我希望能够显示投票总数。这段代码就是为了实现这一点。投票数会随着用户投票实时更新,这意味着我们必须订阅这些数据。为了实现这一点……我将订阅功能从我分享的 graphql.js 代码中移除了。不过不用担心,Hasura Graphiql 提供了一种非常直观的创建订阅的方式( 如下所示 ),只需勾选方框,它就会自动为您输出文本。
完成后,复制生成的订阅并将其粘贴到 graphql.js 文件中以启用它。
我们 v-if
仅在数据加载完成后才显示数据,否则有时您可能会得到一个未定义的对象,而我们不希望这样,对吗?
3️⃣号
<div class= "chart" >
<bar-chart v-if= "loaded" :chartData= "chartData" :options= "options" :width= "200" :height= "300" />
</div>
Enter fullscreen mode
Exit fullscreen mode
在这里,我们导入了用 BarChart.js bar-chart
创建的组件,并使用 和变量 传递数据 。再次看到我们使用 指令仅在数据加载后渲染图表,这样做是为了避免错误。 chartData
options
v-for
添加完这些之后,你就可以设置应用的样式,并 npm run serve
看到一些非常酷的条形图了。这个 Web 应用基本上就是这样实现的。值得一提的是,在构建它的过程中,我考虑过添加和省略某些功能。我省略了一些功能,分别是:
我没有限制每个用户只能投一票
我没有允许用户发起自己的投票
该项目已在GitHub 上发布 ,请随意分叉并添加您需要或想要的任何功能!
一个实时应用程序,用于可视化人们认为谁会在《权力的游戏》第八季第三集中死亡的投票结果。使用 Vue.js、Hasura 和 Chart.js 构建
如果你有任何问题,请在推特 上留言 。希望你喜欢这篇文章。下次再见 :)
文章来源:https://dev.to/malgamves/building-a-real-time-game-of-thrones-voting-app-with-graphql-and-chart-js-37ma