使用 TypeScript、Node、Express 和 Vue 构建 Instagram - 第 5 部分
这是 5 部分教程中的第 5 部分,但每个教程都可以单独阅读,以了解 Node+Express+TypeScript+Vue API/Vue Web 应用程序设置的各个方面。
高级 Vue 模板和图像上传到 Express
想学习移动/桌面应用?这里的技能和概念是基础,并且可复用到移动应用(NativeScript)或桌面应用(Electron)。我可能会在后续课程中介绍它们。
导航至其他部分(您当前位于第 5 部分)
- 使用 TypeScript 设置 Node 和 Express API
- 使用 TypeScript 设置 VueJs
- 使用 Sequelize ORM 设置 Postgres
- 基本 Vue 模板和与 API 的交互
- 高级 Vue 模板和图像上传到 Express
如果还没有,您可以通过克隆和检出 tutorial-part4 分支来开始构建:
git clone https://github.com/calvintwr/basicgram.git
git checkout tutorial-part4
在本教程中,您将完成最后一个功能,即使用 Basicgram 应用程序上传带有图像的帖子,构建 Express API 端点以接收图像,以及另一个端点以生成帖子提要,最后使用 Vue 模板显示它们以完成整个循环。
1. 图像调整大小
首先,您需要在上传之前在客户端调整图片大小。这意味着需要使用 JavaScript 浏览器缩放工具,乍一听这似乎不太好,但考虑到实际情况,其实并非如此。总的来说,客户端调整大小的图片可以加快上传速度,减少服务器带宽消耗,并且非常灵活,用户甚至可以直接从单反相机上传图片。事实上,上传速度并没有那么慢,而且图片效果相当不错Blitz
:
npm install blitz-resize --save
const Blitz = require('blitz-resize')
let blitz = Blitz.create()
blitz.resize({
source: file or event,
height: 640,
width: 640,
output: 'jpg', // or png or gif etc,
outputFormat: image/canvas/data/blob/download,
quality: 0.8 // 80%
}).then(output => {}).catch(err => {})
关于Blitz和图像处理/上传
对于图像处理中的数据类型,通常有两种。第一种dataURI
很方便,可以<img>
像这样附加到 src 中:
<!-- single quote due to XSS Markdown restrictions -->
<img src=`data:image/png;base64,iVBORw0KGgo...`>
第二个是Blob
格式,用于通过 HTTP/HTTPS 上传。
Blitzoutput: 'data'
可以通过使用或 来提供output: 'blob'
,但我们稍后会看到它如何派上用场。
camera.vue
2.准备发布内容的代码:
<!-- camera.vue -->
<template>
<v-ons-page>
<div class="container text-center mx-auto p-1">
<!-- attach the #readFile method to change event -->
<input
type="file"
capture="camera"
accept="image/*"
id="cameraInput"
name="cameraInput"
@change="readFile"
>
<img class="py-2" ref="image">
<!-- `ref` defined for this textarea is a Vue reference which will be handy -->
<textarea
class="py-2 w-full textarea"
rows="3"
placeholder="Write your caption"
ref="caption"
></textarea>
<!-- #post is for uploading the post -->
<button
class="my-2 button"
@click="post"
:disabled="buttonDisabled"
>Post</button>
</div>
</v-ons-page>
</template>
<script lang="ts">
import Vue from "vue"
import Blitz = require('blitz-resize')
import * as superagent from 'superagent'
const blitz = Blitz.create()
export default {
props: {
userName: {
type: String
},
userID: {
type: Number
}
},
data() {
return {
image: { type: Blob }, // this is to store our image
buttonDisabled: true // a flag to turn our button on/off
}
},
methods: {
readFile(event) {
let file = event.srcElement.files[0] // this is where HTML file input puts the file
let self = this
let output;
// super fast resizing
blitz({
source: file,
height: 640,
width: 640,
outputFormat: 'jpg',
// we will use data because we want to update the image in the DOM
output: 'data',
quality: 0.8
}).then(data => {
// update the image so that user sees it.
self.$refs["image"].src = data
// prepare the Blob. Blitz internally has a #dataURItoBlob method.
self.image = Blitz._dataURItoBlob(data)
self.buttonDisabled = false
}).catch(err => {
console.log(err)
})
},
post(event) {
let self = this
this.buttonDisabled = true
let caption = this.$refs["caption"].value // using Vue's $ref property to get textarea.
// Note: To upload image, the request type will be "multipart"
// Superagent automatically takes care of that and you need to
// use `field` for text/plain info, and `attach` for files
superagent
.post('http://localhost:3000/posts/add')
.field('userID', this.userID)
.field('caption', caption)
.attach('photo', this.image)
.then((res: superagent.Response) => {
alert('Successful post. Go to your profile to see it.')
}).catch((err: Error) => {
this.buttonDisabled = false
alert(err)
})
}
}
}
</script>
3.准备 API 来接收帖子
现在我们的视图已准备好发布,我们需要创建 API 端点localhost:3000/posts/add
。
在编写代码之前,我们应该考虑将文件上传到哪里。自然的选择是放在“public/uploads”目录下,但请记住,在教程 1中,我们设置了 TypeScript 编译器,使其在编译到“dist”文件夹(其中也包含 public 文件夹)之前删除整个文件夹。这将在每次编译时删除所有已上传的图片。
因此,您必须将公共文件夹移出,以便与“api”和 src 在同一级别上查看,如下所示:
此外,您需要告诉 Express 您已经更改了公共文件夹,默认情况下,它从该文件夹提供静态文件:
/* api/src/app.ts */
// change
app.use(express.static(join(__dirname, 'public')))
// to
app.use(express.static(join(__dirname, '../public')))
Express 不支持“多部分”请求,所以我们需要一个模块。目前最好的模块是formidable
。你也可以使用multer
和busboy
,但我仍然觉得formidable
的语法最友好。
安装强大:
npm install formidable --save
npm install @types/formidable --save-dev
Formidable 的语法非常灵活,并且是事件驱动的。因此,其理念是将函数附加到事件上。例如,当 HTTP 接收完成所有数据传输时,formidable 会发出事件end
,你可以像这样使用它:
const form = formidable()
function formEndHandler():void { perform some action. }
form.on('end', formEndHandler)
因此,考虑到这一点,我们将创建routes/posts.ts
:
posts.ts
:
import express from 'express'
import { Fields, Files, File } from 'formidable' // typing
import { join } from 'path' // we will use this for joining paths
const formidable = require('formidable') // formidable
const router = express.Router()
const Not = require('you-are-not')
const not = Not.create()
const DB = require('../models/index')
router.get('/', (req: express.Request, res: express.Response, next: express.NextFunction) => {
// get all posts
})
router.post('/add', (req: express.Request, res: express.Response, next: express.NextFunction) => {
const form = formidable({ multiples: true })
let params: any
form.parse(req, (err: Error, fields: Fields, files: Files) => {
params = fields
// use Not to sanitise our received payload
// define a schema
let schema = {
userID: ['string', 'number'],
caption: ['string']
}
// sanitise it
let sanitised = Not.checkObject(
'params',
schema,
params,
{ returnPayload: true }
)
// if sanitised is an array, we will throw it
if(Array.isArray(sanitised)) {
throw Error(sanitised.join(' | ')) // join the errors
}
params = sanitised
})
let fileName: string;
form.on('fileBegin', (name: string, file: File) => {
fileName = name + (new Date().getTime()).toString() + '.jpg'
file.path = join(__dirname, '../../public/uploads', fileName)
})
form.on('error', (err: Error) => {
next(err) // bubbble the error to express middlewares
})
// we let the file upload process complete before we create the db entry.
// you can also do it asynchronously, but will require rollback mechanisms
// like transactions, which is more complicated.
form.on('end', () => {
return DB.Post.create({
User_userID: params.userID,
image: fileName,
caption: params.caption
}).then((post: any) => {
console.log(post)
res.status(201).send(post)
}).catch((err: Error) => {
next(err)
})
})
})
module.exports = router
重新启动服务器并转到您的视图,您应该能够执行以下操作:
如果您意识到的话,调整大小的速度非常快,上传时间也同样如此,因为使用Blitz的客户端压缩,文件大小大大减少了。
现在我们只需要为用户创建端点以获取他/她的所有帖子、个人资料页面,并为主页制作帖子提要。
4. 个人资料页面profile.vue
和 API 端点
你现在应该已经很熟练了。GET /posts/own
获取某个用户所有帖子的端点(我们将其命名为 )其实并不难:
/* routes/posts.ts */
router.get('/own', (req: express.Request, res: express.Response, next: express.NextFunction) => {
// we will receive userID as a string. We want to parse it and make sure
// it's an integer like "1", "2" etc, and not "1.1", "false"
Not.defineType({
primitive: 'string',
type: 'parseable-string',
pass(id: string) {
// TypeScript does not check at runtime. `string` can still be anything, like null or boolean.
// so you need Notjs.
return parseInt(id).toString() === id
}
})
// for GET, the standard is to use querystring.
// so it will be `req.query` instead of `req.body`
not('parseable-string', req.query.userID)
DB.Post.findAll({
where: {
User_userID: req.query.userID
},
order: [ ['id', 'DESC'] ] // order post by id in descending, so the latest will be first.
}).then((posts:any) => {
res.send(posts)
}).catch((err:Error) => {
next(err)
})
})
VueJS Hooks 的详细信息:#created()、#mounted() 等...
接下来是profile.vue
。
VueJS 提供了几个“钩子”,用于准备视图。它们看起来像这样:
<template>
<div> {{ dataFromAPI }} </div>
</template>
<script>
import Vue from 'vue'
export default {
data() {
return {
// this is bound to {{ dataFromAPI }} in the DOM
dataFromAPI: 'Waiting for API call'
}
},
// or created(), depending on when you want it.
mounted() {
// anything inside here gets called when this view is mounted
// you will fetch some data from API.
// suppose API results the results, then doing this:
this.dataFromAPI = results
// will update the value in {{ dataFromAPI }}
}
}
</script>
最常用的是created()
和mounted()
。我们将profile.vue
像这样编写代码:
<!-- profile.vue -->
<template>
<v-ons-page>
<div class="content">
<div class="w-full p-10" style="text-align: center">
{{ userName }}'s Profile
</div>
<!-- Three columns Tailwind class-->
<div v-if="posts.length > 0" class="flex flex-wrap -mb-4">
<div
class="w-1/3"
v-for="post in posts"
:key="post.id"
><img :src="'http://localhost:3000/uploads/' + post.image"></div>
</div>
</div>
</v-ons-page>
</template>
<script lang="ts">
import Vue from 'vue'
import * as superagent from 'superagent'
export default {
props: {
userName: {
type: String
},
userID: {
type: Number
}
},
data() {
return {
posts: { type: Array }
}
},
mounted() {
superagent
.get('http://localhost:3000/posts/own')
.query({ userID: this.userID })
.then((res: superagent.Response) => {
// attach the results to the posts in our data
// and that's it! Vue will update the DOM because it's binded
this.posts = res.body
}).catch((err: Error) => {
alert(err)
})
}
}
</script>
解释:这只是告诉 Vue,当这个视图被挂载时,请为我运行超级代理请求。
提示:由于一些非常奇怪的原因,OnsenUI 需要将所有内容包装在其中
<div class="content">
,否则事情就会开始变得有趣。提示:请注意,我们用 包裹了文章
<div v-if="posts.length > 0">
。这是为了防止 Vue 在 API 调用尚未完成的情况下渲染需要数据的 DOM。如果不这样做,不会有任何异常,只是您会看到一些烦人的控制台日志错误,例如,提示您图片 URL 损坏。
为了简单起见,我们将跳过那些在上传帖子时更新视图的触发器。现在,您必须刷新整个应用程序。
你应该看到:
我们将对 做类似的事情homepage.vue
,使用#created(),它将在稍早一点被调用:
<template>
<v-ons-page>
<div class="content">
<div v-if="posts.length > 0">
<v-ons-card v-for="post in posts" :key="post.id">
<img class="w-full" :src="'http://localhost:3000/uploads/' + post.image">
<div class="py-1 content">
<p class="text-xs font-bold py-2">{{ post.User.name }}<p>
<p class="text-xs text-gray-700">{{ post.caption }}</p>
</div>
</v-ons-card>
</div>
</div>
</v-ons-page>
</template>
<script lang="ts">
import Vue from 'vue'
import * as superagent from 'superagent'
export default {
props: {
userID: {
type: Number
}
},
data() {
return {
posts: { type: Array }
}
},
created() {
superagent
.get('http://localhost:3000/posts/feed')
.query({ userID: this.userID })
.then((res: superagent.Response) => {
this.posts = res.body
}).catch((err: Error) => {
alert(err)
})
}
}
</script>
我们的routes/post.ts
API 中的“/posts/feed”:
router.get('/feed', (req: express.Request, res: express.Response, next: express.NextFunction) => {
not('parseable-string', req.query.userID)
// user's feed is not his/her own posts
DB.Post.findAll({
where: {
User_userID: {
// this is a Sequelize operator
// ne means not equal
// so this means from all post that
// doesn't belong to this user.
[DB.Sequelize.Op.ne]: req.query.userID
}
},
// we want to include the User model for the name
include: [ DB.User],
order: [ ['id', 'DESC'] ] // order post by id in descending, so the latest will be first.
}).then((posts:any) => {
res.send(posts)
}).catch((err:Error) => {
next(err)
})
})
但执行完之后,你会发现你的应用并没有发送userID
API。那是因为我们没有将userID
prop 传递给homepage.vue
。我们可以通过编辑 来解决这个问题home.vue
:
icon: 'fa-home',
label: 'Home',
page: homePage,
key: "homePage",
props: {
userID: {
type: Number // add the userID prop to homePage
}
}
props
提示:你会发现你的应用很快就会变得无法适应 Vue 通过事件发射器传递数据的基本机制。这就是为什么你几乎总是需要Vuex
状态管理,将整个应用可以访问的数据存储在一个地方。
它应该可以工作:
就这样!一张超级粗略的 Instagram 照片。
您可以前往 git repo 克隆这个已完成的应用程序来使用它:
git clone https://github.com/calvintwr/basicgram.git