前言
之前在開發(fā)微信小程序的時候贝椿,發(fā)現(xiàn)官方給每個小程序分配了5g的免費云存儲空間和每個月5g的cdn流量(免費版):
在小程序的開發(fā)后臺可以查看云存儲上的文件蜈块,文件本質(zhì)上是存在cdn上的蔑穴,每個文件都提供了專屬的downLoad url,靠著這個url我們就可以下載部署在云端的文件忠寻,也就是說上傳的文件自帶cdn加速。
5G的空間不算少存和,自己的小程序用不到額外的云存儲資源奕剃,這個資源拿來給自己搭建一個私有云盤豈不美哉?以后自己的一些小文件就可以放在上面捐腿,方便存儲和下載纵朋。諸位如果沒有開發(fā)過小程序也沒有關(guān)系,在微信公眾平臺上隨便申請個工具人小程序茄袖,然后開啟
云開發(fā)
即可操软,我們只是白嫖云存儲空間。項目地址
見文末宪祥。
需求分析
要完成我們的設(shè)想聂薪,我們先羅列下我們需要哪些功能:
- 文件本地上傳到云存儲
- 當(dāng)前文件列表的展示
- 已上傳文件的下載和刪除
- 簡單的登錄和api操作鑒權(quán)
- 具有良好的交互,包括進度條等功能
小程序云存儲的相關(guān)api支持服務(wù)器端調(diào)用品山,不支持瀏覽器直接調(diào)用胆建,所以為了操作云存儲的相關(guān)api,我們需要開啟一個中繼的node服務(wù)作為服務(wù)器,順便管理我們的文件列表肘交。
整個系統(tǒng)的工作流應(yīng)該是這樣的:在我們的前端服務(wù)通過用戶交互笆载,上傳文件到中繼的node服務(wù)上,node服務(wù)器將接收到的文件上傳給小程序的云存儲空間涯呻,獲取返回的文件的相關(guān)信息(主要是download url),同時在數(shù)據(jù)庫內(nèi)維護文件列表的相關(guān)信息(直接存在小程序?qū)?yīng)的數(shù)據(jù)庫中即可)凉驻。前端服務(wù)會請求后端獲取云存儲中的文件列表,通過用戶的交互可對各個文件進行刪除和下載等操作(實際上是向node服務(wù)器發(fā)送請求复罐,由node服務(wù)器調(diào)用官方的各種api來對云端的數(shù)據(jù)進行處理)涝登。
在工具鏈的選擇上,采取react + antd + typescript的技術(shù)方案,后端服務(wù)使用node + express效诅。
核心功能實現(xiàn)
文件上傳
上傳邏輯前端部分
首先我們從數(shù)據(jù)流的源頭開始胀滚,開始搭建文件核心上傳部分index.tsx
:
import React, { useState, useEffect, useReducer } from 'react';
import * as s from './color.css';
import withStyles from 'isomorphic-style-loader/withStyles';
import { Layout, Upload, Card, Button, message, Table, Progress, Spin } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { upload } from '@utils/upload';
import { UploadFile, UploadChangeParam } from 'antd/lib/upload/interface';
import { fileObj, parseList, columns, FileListAction, ProgressObj, ProgressAction } from './accessory';
const { Header, Content, Footer } = Layout;
// 省略部分依賴
function ShowComponent() {
// 文件上傳列表的hooks
const [fileList, setFList] = useReducer(listReducer, []);
// 省略無關(guān)代碼
// ......
async function handleChange(info: UploadChangeParam<UploadFile<any>>) {
const { fileList: newFileList, file } = info;
// 上傳文件的核心邏輯
const ans = await upload(info);
const { fileData = {} } = ans;
if (fileData.fileName) {
setFList({ type: 'update', payload: Object.assign(fileData, { key: fileData._id }) });
message.success(`${info.file.name} 上傳成功。`);
} else {
message.error(`${info.file.name} 上傳失敗乱投。`);
return;
}
}
return (
<Layout className={s.layout}>
<Header>
<div className={s.title}>自己的網(wǎng)盤</div>
</Header>
<Content style={{ padding: '50px 50px' }}>
<div className={s.siteLayoutContent}>
<Upload
customRequest={() => {}}
onChange={handleChange}
showUploadList={false}
multiple={true}
>
<Button>
<UploadOutlined /> Click to Upload
</Button>
</Upload>
</div>
</Content>
</Layout>
)
}
export default withStyles(s)(ShowComponent);
這部分的邏輯很簡單咽笼,主要是通過react+antd搭建UI,使用antd的Upload
控件完成上傳文件的相關(guān)交互戚炫,將獲取到的文件對象傳遞給封裝好的upload
函數(shù)剑刑,接下來我們來看看upload.tsx
中的邏輯:
import {UploadFile, UploadChangeParam } from 'antd/lib/upload/interface';
import { reqPost, apiMap, request, host } from '@utils/api';
import { ProgressObj, ProgressAction } from '../entry/component/content/accessory';
const SIZE = 1 * 1024 * 1024; // 切片大小
// 生成文件切片
function createFileChunk(file: File | Blob | undefined, size = SIZE) {
if (!file) {
return [];
}
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
// 對字節(jié)碼進行切割
fileChunkList.push({ file: file.slice(cur, cur + size) });
cur += size;
}
return fileChunkList;
}
interface FileObj extends File {
name: string;
}
// 發(fā)送單個的文件切片
export async function uploadFile(params: FormData, fileName: string) {
return request(host + apiMap.UPLOAD_FILE_SLICE, {
method: 'post',
data: params,
});
}
// 給服務(wù)器發(fā)送合并切片的邏輯
export async function fileMergeReq(name: string, fileSize: number) {
return reqPost(apiMap.MERGE_SLICE, { fileName: name, size: SIZE, fileSize: fileSize });
}
export async function upload(info: UploadChangeParam<UploadFile<any>>) {
// 獲取切片的文件列表
const fileList = createFileChunk(info.file.originFileObj);
if (!info.file.originFileObj) {
return '';
}
const { name: filename, size: fileSize } = info.file.originFileObj as FileObj;
// 生成數(shù)據(jù)包list
const dataPkg = fileList.map(({ file }, index) => ({
chunk: file,
hash: `${filename}-${index}` // 文件名 + 數(shù)組下標
}));
// 通過formdata依次發(fā)送數(shù)據(jù)包
const uploadReqList = dataPkg.map(({ chunk, hash}) => {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('hash', hash);
formData.append('filename', filename);
return formData
});
const promiseArr = uploadReqList.map(item => uploadFile(item, filename));
await Promise.all(promiseArr);
// 全部發(fā)送完成后發(fā)送合并切片的請求
const ans = await fileMergeReq(filename, fileSize);
callBack({ type: 'delete', fileName: filename });
return ans;
}
這里的邏輯并不復(fù)雜,核心是思想是將用戶上傳的文件切成每個1M的文件切片双肤,并做好標記施掏,將所有的文件切片送到服務(wù)器钮惠,服務(wù)器接收到所有的切片后告知前端接收完成,前端發(fā)送合并請求七芭,告知服務(wù)器可以將所有的文件切片依據(jù)做好的標記合并成原文件素挽。
上傳邏輯server端部分
接下來我們看看服務(wù)器端與之配合的代碼:
let ownTool = require('xiaohuli-package');
let fs = require('fs');
const request = require('request-promise');
const fse = require('fs-extra');
const path = require('path');
const multiparty = require('multiparty');
const { getToken, verifyToken, apiPrefix, errorSend, loginVerify, ENV_ID } = require('../baseUtil');
const { uploadApi, downLoadApi, queryApi, addApi, updateApi } = require('./apiDomain');
const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存儲目錄
// 讀取文件流,并將其pipe到寫文件流
const pipeStream = (path, writableStream) =>
new Promise(resolve => {
const readStream = fse.createReadStream(path);
readStream.on('end', () => {
fse.unlinkSync(path);
resolve()
});
readStream.pipe(writableStream);
})
// 合并接收到文件chunk
const mergeFileChunk = async (filePath, fileName, size) => {
const chunkDir = path.resolve(UPLOAD_DIR, fileName);
const chunkPaths = await fse.readdir(chunkDir);
chunkPaths.sort((a, b) => a.split('-')[1] - b.split('-')[1]);
// 對所有的文件切片完成寫文件流操作
await Promise.all(chunkPaths.map((chunkPath, index) =>
pipeStream(path.resolve(chunkDir, chunkPath),
fse.createWriteStream(filePath, { start: index * size, end: (index + 1) * size })
)
));
// 刪除中間的過渡文件
try {
// 反復(fù)改名啥的很奇怪抖苦,但是不這樣就會有報錯毁菱,導(dǎo)致請求返回pending米死,可能是windows下的bug
// 文件夾的名字和文件名字不能重復(fù)
await fse.move(filePath, path.resolve(UPLOAD_DIR, `p${fileName}`)).catch(e => {
console.log(e)
});
fse.removeSync(chunkDir);
await fse.move(path.resolve(UPLOAD_DIR, `p${fileName}`), path.resolve(UPLOAD_DIR, `${fileName}`)).catch(e => {
console.log(e);
});
} catch(e) {
await fse.move(path.resolve(UPLOAD_DIR, `p${fileName}`), path.resolve(UPLOAD_DIR, `${fileName}`)).catch(e => {
console.log(e)
});
}
}
// 上傳本地合并的文件到云存儲
async function uploadToCloud(filePath, fileName) {
const wxToken = await getToken();
const fullPath = path.resolve(filePath, fileName);
const doamin = uploadApi + wxToken;
// 獲取圖片上傳相關(guān)信息
let a = await ownTool.netModel.post(doamin, {
env: ENV_ID,
path: fileName
})
const { authorization, url, token: newToken, cos_file_id, file_id} = a;
// 真正上傳圖片
const option = {
method: 'POST',
uri: url,
formData: {
"Signature": authorization,
"key": fileName,
"x-cos-security-token": newToken,
"x-cos-meta-fileid": cos_file_id,
"file": {
// 讀取文件流锌历,作為屬性值上傳
value: fs.createReadStream(fullPath),
options: {
filename: 'test',
//contentType: file.type
}
}
}
}
await request(option);
// 獲取圖片的下載鏈接
const getDownDomain = downLoadApi + wxToken;
let imgInfo = await ownTool.netModel.post(getDownDomain, {
env: ENV_ID,
file_list: [{
fileid: file_id,
max_age: 7200
}]
});
// server中轉(zhuǎn)的圖片刪掉
fs.unlink(fullPath, (e) => {
if(e) {
console.log(e);
}
})
return imgInfo;
}
// 更新數(shù)據(jù)庫中的文件列表
async function updateList(fileObj, fileName, size) {
const { download_url, fileid } = fileObj;
const dataInfo = {
fileName,
downloadUrl: download_url,
fileId: fileid,
size,
timeStamp: Date.now()
};
const dataInfoString = JSON.stringify(dataInfo);
const wxToken = await getToken();
let fileId = '';
let isNew = false;
// 先看有沒有同名文件
const res = await ownTool.netModel.post(
queryApi + wxToken, {
env: ENV_ID,
// 查詢數(shù)據(jù)
query: 'db.collection(\"fileList\").where({ fileName: "' + fileName +'"}).get()'
});
// 如果已經(jīng)有了,就更新記錄
if (res.data.length) {
fileId = JSON.parse(res.data[0])._id;
const res1 = await ownTool.netModel.post(updateApi + wxToken, {
env: ENV_ID,
// query語句峦筒,功能是給filelist這個集合更新數(shù)據(jù)
query: 'db.collection(\"fileList\").where({ fileName: "' + fileName + '"}).update({ data: ' + dataInfoString +'})'
})
// 否則新建一個
} else {
const res2 = await ownTool.netModel.post(addApi + wxToken, {
env: ENV_ID,
// query語句究西,功能是給filelist這個集合添加數(shù)據(jù)
query: 'db.collection(\"fileList\").add({ data: ' + dataInfoString +'})'
})
fileId = res2.id_list[0];
isNew = true;
}
const finalData = Object.assign(dataInfo, { _id: fileId });
return { fileData: finalData, isNew };
}
function uploadFileApi(app) {
// 接收上傳的文件片段
app.post(apiPrefix + '/uploadFile', async function(req, res) {
// 通過multiparty這個庫解析上傳的form data,并生成本地文件蠢哭
const multipart = new multiparty.Form();
multipart.parse(req, async (err, fields, files) => {
if (err) {
console.log(err);
return;
}
const [chunk] = files.chunk;
const [hash] = fields.hash;
const [filename] = fields.filename;
const chunkDir = path.resolve(UPLOAD_DIR, filename);
if (!fse.existsSync(chunkDir)) {
await fse.mkdirs(chunkDir).catch(e => {
console.log(e)
});
}
await fse.move(chunk.path, `${chunkDir}/${hash}`);
res.end('received file chunk');
})
})
// 合并文件
app.post(apiPrefix + '/fileMergeReq', async function(req, res) {
const { fileName, size, fileSize } = req.body;
const filePath = path.resolve(UPLOAD_DIR, `${fileName}`, `${fileName}`);
// 合并文件chunk
await mergeFileChunk(filePath, fileName, size);
// 上傳文件到云存儲
const fileInfo = await uploadToCloud(UPLOAD_DIR, `${fileName}`);
// 更新文件列表
const dbInfo = await updateList(fileInfo.file_list[0], fileName, fileSize);
res.send(dbInfo);
})
}
exports.uploadFileApi = uploadFileApi;
這里涉及到了小程序http api的調(diào)用,調(diào)用前需要獲取調(diào)用token,再配合相關(guān)參數(shù)完成請求物喷,詳情請查閱官方文檔卤材,這里的邏輯與前端一一對應(yīng),首先是接受前端上傳過來的文件切片峦失,將他們解析并保存到臨時目錄扇丛,等到前端發(fā)送過來文件合并的請求后,將先前接受到的文件切片合并成原始文件尉辑。隨后調(diào)用小程序官方api,將本地的文件上傳到云存儲上帆精,根據(jù)返回的fileId
,獲取文件部署在cdn上的download url
隧魄,并將其返回給前端卓练。
文件列表展示
文件上傳的核心功能完成之后,接下來要處理的是文件列表的展示购啄,這里我們使用react中的hooks來作為狀態(tài)管理的工具襟企。更新index.tsx
中的代碼
import { usePageManager, getQueryString, SINGLE_PAGE_SIZE } from '@utils/commonTools';
// 省略部分依賴
function ShowComponent() {
// 控制頁碼的自定義hook
const [pageObj, setPage] = usePageManager();
// 選中的文件的狀態(tài)
const [chekcList, setCheckList] = useState([]);
function listReducer(state: Array<fileObj>, action: FileListAction): Array<fileObj> {
// 文件列表狀態(tài)更新
const fileUpdate = () => {
// 找出要更新的文件
const index = state.findIndex(item => item._id === action.payload._id);
// 如果找不到,表示是新增
if (index >= 0) {
const target = state[index];
// 修改時間戳
target.timeStamp = action.payload.timeStamp;
return [...state.slice(0, index), target, ...state.slice(index + 1)];
} else {
// 新增文件
return (action?.payload ? [action.payload] : []).concat([...state])
}
}
const actionMap = {
// 初始化內(nèi)容
init: () => action?.list || [],
update: fileUpdate,
// 刪除文件
delete: () => state.filter(item => action.keys.findIndex(sitem => sitem ===item._id) === -1)
};
return actionMap[action.type]();
}
// 文件列表的狀態(tài)
const [fileList, setFList] = useReducer(listReducer, []);
// 初始化內(nèi)容
useEffect(() => {
const initList = async function() {
// 向后端查詢文件列表內(nèi)容
const res = await post(apiMap.QUERY_LIST, {
queryString: getQueryString(1)
});
const list = parseList(res);
// 設(shè)置總頁碼
setPage({ total: res.pager.Total });
// 初始化文件列表
setFList({ type: 'init', list })
};
initList();
}, []);
async function handleChange(info: UploadChangeParam<UploadFile<any>>) {
// 省略部分代碼
}
// table點擊下一頁時的回調(diào)
async function detail(page: number) {
// 查詢下一頁的內(nèi)容
const res = await post(apiMap.QUERY_LIST, {
queryString: getQueryString(page)
});
const showList = parseList(res);
// 設(shè)置頁碼
setPage({ current: page, total: res.pager.Total });
// 重置文件列表
setFList({ type: 'init', list: showList });
}
async function deleteFile() {
const deleteList = fileList.filter(item => chekcList.findIndex(sitem => item._id === sitem) >= 0)
.map(item => item.fileId);
await post(apiMap.DELETE_FILE, {
deleteFileList: deleteList
});
setFList({ type: 'delete', keys: chekcList });
}
function getNotification() {
// 省略部分代碼
}
const paginaConfig = {
onChange: detail,
total: pageObj.total,
current: pageObj.current,
pageSize: SINGLE_PAGE_SIZE,
};
return (
<Layout className={s.layout}>
<Header>
<div className={s.title}>自己的網(wǎng)盤</div>
</Header>
{getNotification()}
<Content style={{ padding: '50px 50px' }}>
<div className={s.siteLayoutContent}>
<Upload
customRequest={() => {}}
onChange={handleChange}
showUploadList={false}
multiple={true}
>
<Button>
<UploadOutlined /> Click to Upload
</Button>
</Upload>
<Button className={s.deleteBtn} onClick={deleteFile} type='dashed'>刪除</Button>
<Button className={s.downLBtn} onClick={downloadFile} type='primary'>下載</Button>
<Table
rowSelection={{
type: 'checkbox',
onChange: (selectedRowKeys, selectedRows) => {
setCheckList(selectedRowKeys);
},
}}
pagination={paginaConfig} columns={columns} dataSource={fileList} />
</div>
</Content>
<Footer style={{ textAlign: 'center' }}>Produced by 廣蘭路地鐵</Footer>
</Layout>
)
}
這里我們使用了三個hook來協(xié)助我們管理狀態(tài)狮含,usePageManager
這個自定義hook來控制文件列表的切頁狀態(tài)顽悼,const [chekcList, setCheckList] = useState([]);
來控制多個文件的選中態(tài)(下圖中的checkbox):
當(dāng)需要對文件進行多選時,通過
setCheckList
來控制當(dāng)前選中的文件列表几迄,通過文件的唯一_id
來標識不同的文件蔚龙。具體可進行下載或者刪除等操作。const [fileList, setFList] = useReducer(listReducer, []);
來控制文件列表狀態(tài)乓旗。useEffect
配合空數(shù)組做參數(shù)進行fileList
的初始化(可以類比傳統(tǒng)class component的componentDidMount方法)府蛇,向服務(wù)器請求文件列表,將內(nèi)容解析后通過antd
的table
組件渲染在頁面上屿愚,table
切頁時會根據(jù)pagination
上注冊的onChange事件根據(jù)當(dāng)前的頁碼去拉取新的內(nèi)容并更新table汇跨。在文件上傳完畢务荆,或者刪除時,都需要更新fileList
的狀態(tài)穷遂,此時調(diào)用setFList
來更新當(dāng)前的文件列表函匕。
文件下載
這一部分的內(nèi)容相對簡單,這里筆者采取的方案是通過構(gòu)造form表單蚪黑,通過設(shè)置get
method然后submit表單來完成文件的下載:
export function downloadUrlFile(url) {
let tempForm = document.createElement('form')
tempForm.action = url
tempForm.method = 'get'
tempForm.style.display = 'none'
document.body.appendChild(tempForm)
tempForm.submit()
return tempForm
}
實現(xiàn)效果大致這樣:
關(guān)于文件盅惜,延伸出來的內(nèi)容不少,我有另外一篇文章進行了比較細致的分析忌穿,感興趣的朋友可以移步關(guān)于點擊下載文件的那些事
增加上傳進度條抒寂,優(yōu)化體驗
作為一個合格的網(wǎng)盤,沒有上傳進度條體驗是很糟糕的掠剑。那么如何實現(xiàn)呢屈芜?顯然,在server端的文件合并朴译,上傳至云盤等步驟是沒有辦法量化的井佑,很難用進度條的形式展示,唯一前端可控的就是文件切片與上傳的過程眠寿。首先我們要定義一個新的列表來標識正在上傳的文件的切片進度:
// index.tsx
function ShowComponent() {{
// 省略重復(fù)代碼
// 定義控制文件上傳狀態(tài)的hooks
const [uploadProgressList, setUploadPL] = useReducer(uploadProFunc, []);
// 維護上傳列表的進度條
function uploadProFunc(state: Array<ProgressObj>, action: ProgressAction): Array<ProgressObj> {
const progressUpdate = () => {
const index = state.findIndex(item => item.fileName === action.fileName);
if (index >= 0) {
const target = state[index];
target.finishedChunks += action.finishedChunks;
return [...state.slice(0, index), target, ...state.slice(index + 1)];
} else {
return (action?.payload ? [action.payload] : []).concat([...state])
}
}
const actionMap = {
update: progressUpdate,
delete: () => state.filter(item => item.fileName !== action.fileName)
};
return actionMap[action.type]();
}
}
我們需要將setUploadPL
作為回調(diào)傳入文件上傳的操作中躬翁,在每個切片完成之后更新目標文件的進度:
// index.tsx
function ShowComponent() {
// 省略部分內(nèi)容
async function handleChange(info: UploadChangeParam<UploadFile<any>>) {
const { fileList: newFileList, file } = info;
// console.log(info);
const ans = await upload(info, setUploadPL);
// 省略重復(fù)部分
}
}
繼續(xù)更新upload.tsx
:
export async function uploadFile(params: FormData, fileName: string, cb: React.Dispatch<ProgressAction>) {
return request(host + apiMap.UPLOAD_FILE_SLICE, {
method: 'post',
data: params,
}).then(res => {
// 追加回調(diào)函數(shù),更新上傳進度
cb({ type: 'update', fileName, finishedChunks: 1})
});
}
export async function upload(info: UploadChangeParam<UploadFile<any>>, callBack: React.Dispatch<ProgressAction>) {
// 省略部分內(nèi)容
// 創(chuàng)建文件上傳對象
const initPro = {
fileName: filename,
fullChunks: uploadReqList.length,
finishedChunks: 0
} as ProgressObj;
// 創(chuàng)建一個文件上傳的狀態(tài)
callBack({ type: 'update', fileName: filename, payload: initPro });
// 追加回調(diào)
const promiseArr = uploadReqList.map(item => uploadFile(item, filename, callBack));
// 省略部分內(nèi)容
}
這里簡單解釋下邏輯盯拱,在每次上傳文件時盒发,計算總計的文件切片數(shù),然后通過調(diào)用傳入的回調(diào)更新文件上傳的狀態(tài)坟乾,在上傳文件切片的過程中迹辐,每個切片上傳完畢后,更新該文件上傳狀態(tài)的finishedChunks
屬性,至此甚侣,我們已經(jīng)能夠追蹤文件的上傳態(tài)明吩,接下來要做的就是利用文件上傳的狀態(tài)來繪制進度條,繼續(xù)補充index.tsx
中的代碼殷费。
function ShowComponent() {
// 省略部分代碼
function getNotification() {
const statusList = uploadProgressList.map((item, index) => {
const { fileName, fullChunks, finishedChunks } = item;
const percent = finishedChunks / fullChunks;
return <div key={fileName} className={s.box}>
<div>正在上傳:{fileName}</div>
<Progress percent={percent * 100} status="active"/>
{percent === 1 ? <div className={s.uploading}>
<div className={s.loadingW}>正在等待服務(wù)器響應(yīng) </div>
<Spin />
</div>
: null}
</div>
})
return (
<div className={s.boxwrapper}>
{statusList}
</div>
)
}
return (
<Layout className={s.layout}>
// 省略部分代碼
{getNotification()}
</Layout>
}
這里使用了antd
中的Progress
進度條和Spin
loading組件來協(xié)助展示印荔,大致效果如下:
簡單鑒權(quán)
既然是專屬云盤,后續(xù)肯定是要部署到公網(wǎng)上的详羡,為了避免被其他人誤操作或刻意破壞仍律,我們有必要加上登錄和鑒權(quán)的機制,在項目的入口文件实柠,我們添加上登錄頁的路由:
import React from 'react';
import ReactDom from 'react-dom';
import Com from './component/content';
import Login from './component/login';
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import StyleContext from 'isomorphic-style-loader/StyleContext';
// : any[]
const insertCss = (...styles: any[]) => {
const removeCss = styles.map(style => style._insertCss())
return () => removeCss.forEach(dispose => dispose())
}
// 掛載組件
const mountNode = document.getElementById('main');
ReactDom.render(
<StyleContext.Provider value={{ insertCss }}>
<Router>
<Switch>
// 登錄頁
<Route path='/cloudDisk/login.html' component={Login} />
// 內(nèi)容頁
<Route path='/cloudDisk/disk.html' component={Com} />
<Route path='/cloudDisk/' component={Login} />
</Switch>
</Router>
</StyleContext.Provider>,
mountNode
);
登錄頁login.tsx
的實現(xiàn)非常簡單水泉,一個輸入框加摁鈕即可:
import React, { useState } from 'react';
import * as s from './index.css';
import withStyles from 'isomorphic-style-loader/withStyles';
import { Button, Form, Input, message } from 'antd';
import { post, apiMap } from '@utils/api';
const FormItem = Form.Item;
function Login() {
const [secret, setSecret] = useState('');
const info = async function(event: React.ChangeEvent<HTMLInputElement>) {
setSecret(event.target.value);
}
async function enter() {
// 請求接口,驗證身份
const res = await post(apiMap.LOGIN, {
password: secret
});
// 如果鑒權(quán)成功,在localStorage中設(shè)置token,所有的請求都會帶上token以便server端的校驗
if (res.verifyResult) {
localStorage.setItem('tk', res.accessToken);
window.location.href='/cloudDisk/disk.html';
} else {
message.error('密碼錯誤!');
}
}
return (
<div className={s.bg}>
<div className={s.title}>歡迎進入DIY云盤</div>
<div className={s.wrapper}>
<Form >
<FormItem className={s.input}>
<Input.Password onChange={info}/>
</FormItem>
</Form>
<Button className={s.button} onClick={enter} type='primary'>Submit</Button>
</div>
</div>
)
}
export default withStyles(s)(Login);
鑒權(quán)邏輯的核心是在登錄頁設(shè)置校驗草则,登錄成功后后端將返回一個token
钢拧,前端將此token存放在localStorage中,之后的所有請求都會帶上這個token以便后端校驗炕横,校驗通過可以進行后續(xù)操作源内,否則返回錯誤碼,前端強制跳轉(zhuǎn)登錄頁份殿。在接口請求側(cè)膜钓,我們統(tǒng)一添加token,相關(guān)邏輯在api.tsx
中:
import { extend } from 'umi-request';
/**
* 配置request請求時的默認參數(shù)
*/
const qulifiedRequest = extend({
errorHandler, // 默認錯誤處理
credentials: 'include', // 默認請求是否帶上cookie
});
qulifiedRequest.use(async (ctx, next) => {
await next();
const { res } = ctx;
// 如果是特殊的錯誤碼卿嘲,表示鑒權(quán)失敗直接跳轉(zhuǎn)登錄頁
if (res?.response?.status === '401') {
notification.error({
message: `請求錯誤 鑒權(quán)失敗`,
description: '鑒權(quán)失敗,請重新登陸',
});
setTimeout(() => window.location.href='/cloudDisk/login.html', 2000,);
}
});
// 帶鑒權(quán)的接口
export const reqPost = (url: string, para: object) =>
qulifiedRequest(localHost + url, {
method: 'post',
// 所有請求都帶上默認token
data: Object.assign({}, para, {
token: localStorage.getItem('tk')
}),
}
);
在server端颂斜,我們需要對所有的接口請求添加默認的攔截邏輯,首先校驗前端請求的token是否正確腔寡,如果不正確將返回統(tǒng)一的錯誤碼焚鲜,正確將繼續(xù)后續(xù)的處理邏輯。在處理登錄請求時放前,如果密碼正確,server將根據(jù)當(dāng)前的時間戳和秘鑰生成一個token糯彬,并將其返回給前端凭语,相關(guān)內(nèi)容在app.js
中:
var express=require('express');
var app =express();
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
const { verifyToken, secret, apiPrefix, errorSend, loginVerify } = require('./baseUtil');
var jwt = require('jwt-simple');
app.use(bodyParser.json());
app.use(cookieParser());
app.use(bodyParser.urlencoded({extended: true}));
//設(shè)置跨域訪問
app.all('*', function(req, res, next) {
// 省略部分內(nèi)容
const rawUrl = req.url;
// 統(tǒng)一處理鑒權(quán)邏輯
if (!pathNotVerify.includes(rawUrl)) {
if (verifyToken(req.body)) {
// 如果通過鑒權(quán),下一步
next()
} else {
// 否則返回特殊錯誤碼
errorSend(res);
}
} else {
next();
}
});
//登陸接口
app.post(apiPrefix + '/login', async function(req,res){
const { password } = req.body;
const verifyObj = await loginVerify(password);
// 如果密碼正確撩扒,返回簽發(fā)的token
if (verifyObj.verifyResult) {
res.send({
verifyResult: true,
// 用戶請求的鑒權(quán)token似扔,使用jwt-simple這個庫生成
accessToken: jwt.encode(Object.assign(req.body, { tokenTimeStamp: Date.now() } ), secret)
})
} else {
res.send({
verifyResult: false,
});
}
});
server如何驗證token呢?如果token的簽發(fā)時間在2個小時之內(nèi),我們就認為token有效:
const jwt = require('jwt-simple');
const outOfDatePeriod = 2 * 60 * 60 * 1000;
const verifyToken = ({token = ''}) => {
// 根據(jù)秘鑰反向解析token搓谆,獲取簽發(fā)時的時間戳
const res = token ? jwt.decode(token, secret) : {};
return (res.tokenTimeStamp + outOfDatePeriod) > Date.now();
}
至此主體工程全部完工炒辉。
部署和持續(xù)集成
部署按照個人的習(xí)慣來就好,直接在云機器上起express
也好泉手,用nginx
也好黔寇,tomcat
亦可,詳細細節(jié)很多斩萌,這里由于篇幅原因不再贅述缝裤,只是推薦筆者之前寫的一篇新手向入門帖docker+nginx+node+jenkins從零開始部署你的前端服務(wù),事無巨細地介紹了從云機器配置到jenkins
持續(xù)集成的全流程颊郎。
參考鏈接
項目前端代碼git地址
項目后端代碼git地址
小程序云開發(fā)https api官方文檔
關(guān)于點擊下載文件的那些事
docker+nginx+node+jenkins從零開始部署你的前端服務(wù)
字節(jié)跳動面試官:請你實現(xiàn)一個大文件上傳和斷點續(xù)傳