使用 TypeScript、Node、Express 和 Vue 构建 Instagram - 第 5 部分

2025-06-04

使用 TypeScript、Node、Express 和 Vue 构建 Instagram - 第 5 部分

这是 5 部分教程中的第 5 部分,但每个教程都可以单独阅读,以了解 Node+Express+TypeScript+Vue API/Vue Web 应用程序设置的各个方面。

高级 Vue 模板和图像上传到 Express

想学习移动/桌面应用?这里的技能和概念是基础,并且可复用到移动应用(NativeScript)或桌面应用(Electron)。我可能会在后续课程中介绍它们。

导航至其他部分(您当前位于第 5 部分

  1. 使用 TypeScript 设置 Node 和 Express API
  2. 使用 TypeScript 设置 VueJs
  3. 使用 Sequelize ORM 设置 Postgres
  4. 基本 Vue 模板和与 API 的交互
  5. 高级 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.vue2.准备发布内容的代码:

<!-- 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 在同一级别上查看,如下所示:

快速文件夹结构 - 将公共文件夹移出“src”

此外,您需要告诉 Express 您已经更改了公共文件夹,默认情况下,它从该文件夹提供静态文件:

/* api/src/app.ts */

// change
app.use(express.static(join(__dirname, 'public')))

// to
app.use(express.static(join(__dirname, '../public')))

Express 不支持“多部分”请求,所以我们需要一个模块。目前最好的模块是formidable。你也可以使用multerbusboy,但我仍然觉得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

重新启动服务器并转到您的视图,您应该能够执行以下操作:

模拟 Instagram:客户端调整大小、express+nodejs 文件处理

如果您意识到的话,调整大小的速度非常快,上传时间也同样如此,因为使用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 损坏。

为了简单起见,我们将跳过那些在上传帖子时更新视图的触发器。现在,您必须刷新整个应用程序。

你应该看到:

VueJS 模板数据绑定和通过 AJAX/API 调用更新

替代文本

我们将对 做类似的事情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.tsAPI 中的“/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)
    })
})

但执行完之后,你会发现你的应用并没有发送userIDAPI。那是因为我们没有将userIDprop 传递给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状态管理,将整个应用可以访问的数据存储在一个地方。

它应该可以工作:

使用 Express 和 Vue 在 TypeScript 中构建 Instagram
替代文本

就这样!一张超级粗略的 Instagram 照片。

您可以前往 git repo 克隆这个已完成的应用程序来使用它:

git clone https://github.com/calvintwr/basicgram.git
文章来源:https://dev.to/calvintwr/build-instagram-using-typescript-node-express-and-vue-part-5-gga
PREV
别再自欺欺人了。我们所谓的 CI/CD 其实只是 CI。但 CI 工具还不够吗?云原生 CD 是可能的
NEXT
使用 TypeScript、Node、Express 和 Vue 构建 Instagram - 第一部分