跳到主要内容

投票系统项目

项目技术栈

react socketio express sqlite

redux redux-saga

socket.io

ws+socket.io

作为新一代的 web 标准,HTML5 为我们提供了很多有用的东西,比如 canvas,本地存储(已经分离出去了),多媒体编程接口,当然还有我们的 WebSocket。WebSocket 是 HTML5 开始提供的一种浏览器与服务器间进行全双工通讯(full-duplex)的网络技术,可以传输基于信息的文本和二进制的数据。它于 2011 年被 IETF 定为标准 RFC 6455,同时 WebSocket API 也被 W3C 定为标准。

实时 web 应用的需求

web 应用的信息交互过程我想大家或多或少都知道一些,通常是客户端通过浏览器发出一个请求,然后服务器端在接受和审核请求后,进行处理并将结果返回给客户端,最后由客户端的浏览器将信息呈现出来。这种通信机制在信息交互不是特别频繁的情况下并没有太大的问题,但对于那些实时性要求高、海量数据并发的应用来说,就显得捉襟见肘了,比如现在常见的网页游戏,证券网站,RSS 订阅推送,网页实时对话,打车软件等。通常当客户端准备呈现一些信息时,这些信息在服务器端很有可能就已经过时了。为了满足以上那些场景,大佬们研究出来了一些折衷方案,其中最常用的就是普通轮询和 Comet 技术,而 Comet 技术实际上就是轮询的改进,细分起来 Comet 有两种实现方式:

  • 长轮询机制
  • 流技术机制

长轮询机制

长轮序是对普通轮询的改进和提高。普通轮询简单来说,就是客户端每隔一定的时间就向服务器端发送请求,从而以频繁请求的方式来保持客户端和服务器端的同步。这种同步方案的最大问题是,客户端已固定的频率发送请求时,很可能服务端的数据没有更新,产生很多无用的网络传输,非常低效。

为了减少无效的网络传输,长轮询对普通轮询进行了改进和提高,当服务器端没有数据更新时,链接会保持一段时间的周期,直到数据或状态发生改变或连接时间过期,通过这种机制我们就可以减少很多无效的客户端和服务器间的交互。当然,如果服务器端的数据变更非常频繁的话,这种机制并没有有效的提高性能,和普通轮询没有太大的区别,且长轮询也会耗费更多的资源,比如 CPU,内存,带宽等。

流技术机制

流技术机制简单来说就是客户端的页面使用一个隐藏的窗口向服务端发出一个长连接的请求。服务器接到请求后作出回应,并不断更新状态,以保证客户端和服务器端的连接不过期。通过这种机制就可以将服务器端的信息不断传向客户端,从而保证信息的时效性。但这种机制对于用户体验并不友好,需要针对不同的浏览器升级不同的方案来改进用户体验,同时这种机制如果在并发情况下发生时,会对服务器的资源造成很大压力。

WebSocket

正是出于以上几种解决方案都有着各自的局限性,HTML5 WebSocket 也就应运而生了,浏览器可以通过 JavaScript 借助现有的 HTTP 协议来向服务器发出 WebSocket 连接的请求,当连接建立后,客户端和服务器端就可以直接通过 TCP 连接来直接进行数据交换。这是由于 websocket 协议本质上就是一个 TCP 连接,所以在数据传输的稳定性和传输量上有所保证,且相对于以往的轮询和 Comet 技术在性能方面也有了长足的进步: image

有一点需要注意的是虽然 websocket 在通信时需要借助 HTTP,但它本质上和 HTTP 有着很大的区别:

  • WebSocket 是一种双向通信协议,在建立连接之后,WebSocket 服务端和客户端都能主动向对方发送或者接受数据。
  • WebSocket 需要先连接,只有再连接后才能进行相互通信。

他们的关系其实就和这张图表现的一样,虽然有相交的部分,但依然有着很大的区别:

image

WebSocket API 的用法

由于每个服务器端的语言都有着自己的 API,因此首先我们来讨论客户端的 API:

// 创建一个socket实例:
const socket = new WebSocket(ws://localhost:9093')
// 打开socket
socket.onopen = (event) => {
// 发送一个初始化消息
socket.send('Hello Server!')
// 服务器有响应数据触发
socket.onmessage = (event) => {
console.log('Client received a message',event)
}
// 出错时触发,并且会关闭连接。这时可以根据错误信息进行按需处理
socket.onerror = (event) => {
console.log('error')
}
// 监听Socket的关闭
socket.onclose = (event) => {
console.log('Client notified socket has closed',event)
}
// 关闭Socket
socket.close(1000, 'closing normally')
}

但有几点我们需要注意:

  • 在创建 socket 实例的时候,new WebSocket()接受两个参数,第一个参数是 ws 或 wss,第二个参数可以选填自定义协议,如果是多协议,可以是数组的方式。
  • WebSocket 中的 send 方法不是任何数据都能发送的,现在只能发送三类数据,包括 UTF-8 的 string 类型(会默认转化为 USVString),ArrayBuffer 和 Blob,且只有在建立连接后才能使用。(感谢大佬指出错误,已修改)
  • 在使用 socket.close(code,[reason])关闭连接时,code 和 reason 都是选填的。code 是一个数字值表示关闭连接的状态号,表示连接被关闭的原因。如果这个参数没有被指定,默认的取值是 1000 (表示正常连接关闭),而 reason 是一个可读的字符串,表示连接被关闭的原因。这个字符串必须是不长于 123 字节的 UTF-8 文本。

我们在上面提到过,创建一个 socket 实例时可以选填 ws 和 wss 来进行通信协议的确定。他们两个其实很像 HTTP 和 HTTPS 之间的关系。其中 ws 表示纯文本通信,而 wss 表示使用加密信道通信(TCP+TLS)。那为啥不直接使用 HTTP 而要自定义通信协议呢?这就要从 WebSocket 的目的说起来,WebSocket 的主要功能就是为了给浏览器中的应用与服务器端提供优化的,双向的通信机制,但这不代表 WebScoket 只能局限于此,它当然还能够用于其他的场景,这就需要他可以通过非 HTTP 协议来进行数据交换,因此 WebSocket 也就采用了自定义 URI 模式,以确保就算没有 HTTP,也能进行数据交换。

以下是一个典型的 WebSocket 发起请求到响应请求的示例:

客户端到服务端:
GET / HTTP/1.1
Connection:Upgrade
Host:127.0.0.1:8088
Origin:null
Sec-WebSocket-Extensions:x-webkit-deflate-frame
Sec-WebSocket-Key:puVOuWb7rel6z2AVZBKnfw==
Sec-WebSocket-Version:13
Upgrade:websocket

服务端到客户端:
HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Server:beetle websocket server
Upgrade:WebSocket
date: Thu, 10 May 2018 07:32:25 GMT
Access-Control-Allow-Credentials:true
Access-Control-Allow-Headers:content-type
Sec-WebSocket-Accept:FCKgUr8c7OsDsLFeJTWrJw6WO8Q=

我们可以看到,WebSocket 协议和 HTTP 协议乍看并没有太大的区别,但细看下来,区别还是有些的,这其实是一个握手的 http 请求,首先请求和响应的,”Upgrade:WebSocket”表示请求的目的就是要将客户端和服务器端的通讯协议从 HTTP 协议升级到 WebSocket 协议。从客户端到服务器端请求的信息里包含有”Sec-WebSocket-Extensions”、“Sec-WebSocket-Key”这样的头信息。这是客户端浏览器需要向服务器端提供的握手信息,服务器端解析这些头信息,并在握手的过程中依据这些信息生成一个 28 位的安全密钥并返回给客户端,以表明服务器端获取了客户端的请求,同意创建 WebSocket 连接。

当握手成功后,这个时候 TCP 连接就已经建立了,客户端与服务端就能够直接通过 WebSocket 直接进行数据传递。不过服务端还需要判断一次数据请求是什么时候开始的和什么时候是请求的结束的。在 WebSocket 中,由于浏览端和服务端已经打好招呼,如我发送的内容为 utf-8 编码,如果我发送 0x00,表示包的开始,如果发送了 0xFF,就表示包的结束了。这就解决了黏包的问题

Socket.IO

简单来说 Socket.IO 就是对 WebSocket 的封装,并且实现了 WebSocket 的服务端代码。Socket.IO 将 WebSocket 和轮询(Polling)机制以及其它的实时通信方式封装成了通用的接口,并且在服务端实现了这些实时机制的相应代码。也就是说,WebSocket 仅仅是 Socket.IO 实现实时通信的一个子集。Socket.IO 简化了 WebSocket API,统一了返回传输的 API。传输种类包括:

  • WebSocket
  • Flash Socket
  • AJAX long-polling
  • AJAX multipart streaming
  • IFrame
  • JSONP polling。

我们来看一下服务端的 Socket.IO 基本 API:

// 引入socke.io
const io = require('socket.io')(80)
// 监听客户端连接,回调函数会传递本次连接的socket
io.on('connection',function(socket))
// 给所有客户端广播消息
io.sockets.emit('String',data)
// 给指定的客户端发送消息
io.sockets.socket(socketid).emit('String', data)
// 监听客户端发送的信息
socket.on('String',function(data))
// 给该socket的客户端发送消息
socket.emit('String', data)

另外,Socket.IO 还提供了一个 Node.JS API,它看起来很像客户端 API。所以我们来看看它的实际应用吧:

// socket-server.js

// 需要使用HTTP模块来启动服务器和Socket.IO
const http= require('http'),
const io= require('socket.io')

const server= http.createServer(function(req, res){
// 发送HTML的headers和message
res.writeHead(200,{ 'Content-Type': 'text/html' })
res.end('<p>Hello Socket.IO!<p>')
});
// 在8080端口启动服务器
server.listen(8080)

// 创建一个Socket.IO实例,并把它传递给服务器
const socket= io.listen(server)

// 添加一个连接监听器
socket.on('connection', function(client) {

// 连接成功,开始监听
client.on('message',function(event){
console.log('Received message from client!',event)
})
// 连接失败
client.on('disconnect',function(){
clearInterval(interval)
console.log('Server has disconnected')
})
})

然后我们就可以启动这个文件了:

node socket-server.js

然后我们就可以创建一个每秒钟发送消息到客户端的发送器了;

var interval= setInterval(function() {
client.send('This is a message from the server,hello world' + new Date().getTime());
},1000);

注:需要注意的是,如果我们想在前端使用 socket.IO,我们需要下载这个:

npm install socket.io-client --save

然后再连接网络:

import io from 'socket.io-client'
const socket = io('ws://localhost:8080')

项目使用

前端引入 socket.io-client

import io from "socket.io-client";
var match = useRouteMatch();
var history = useHistory();

var { vote, options } = voteFetcher.read(match.params.id);
var [optionsInfo, setOptionsInfo] = useState(options);

useEffect(() => {
setOptionsInfo(options);
}, [options]);

var userInfo = useUserInfo(); //当前登陆用户

// 向某个选项投票
/**
* id 选项id
* selected 当前用户是否选中该选项
*/
async function voteOption(id, selected) {
if (!userInfo) {
history.push("/login");
return;
}
if (!selected) {
await axios.post("/vote/voteup/" + id);
console.log("vote ok");
} else {
await axios.post("/vote/cancel/" + id);
console.log("cancel ok");
}
}

useEffect(() => {
// 如果没过期
if (vote.deadline > new Date().toISOString()) {
var socket = io({
// transports: ['websocket', 'polling']
});
socket.emit("select root", vote.id);
//收到了本问题下投票的新信息
socket.on("voting info", (info) => {
console.log(info);
setOptionsInfo(info);
});
return () => socket.disconnect();
}
}, [vote.id]);

var allUsers = useMemo(() => {
var users = optionsInfo.reduce((ary, option) => {
ary.push(...option.Users);
return ary;
}, []);
var uniqUsers = uniqBy(users, "id");
return uniqUsers;
}, [optionsInfo]);

服务端应用

server.js 里面新建服务

const http = require("http");
const SocketIO = require("socket.io");
const server = http.createServer();
const io = SocketIO();

exports.server = server;
exports.io = io;
const { server, io } = require("./servers"); // http Server对象

server.on("request", app);

io.attach(server, { serveClient: false }); //一定程度接管server的功能,

server.listen(PORT, () => {
console.log("listening on port", PORT);
});

react-redux

useSelector, useDispatch 使用

export default function Home() {
console.log("home render");
var history = useHistory();
var user = useSelector((state) => state.user);
var tried = useSelector((state) => state.triedLogin);

var dispatch = useDispatch();

useEffect(() => {
(async () => {
if (user) {
return;
}
if (tried) {
history.push("/login");
}
if (!user) {
dispatch({ type: "get-user-info" });
}
})();
}, [user, tried]);

return (
<div>
<div>
<Link to="/create">创建单选</Link>
</div>
<div>
<Link to="/create?multiSelect">创建多选</Link>
</div>
</div>
);
}

store 使用

import { createStore, applyMiddleware } from "redux";
import createSagaMiddleware from "redux-saga";
import { put, takeEvery, takeLatest, call } from "redux-saga/effects";
import axios from "axios";

const saga = createSagaMiddleware();

const initialState = {
user: null,
votes: {},
triedLogin: false, //尝试登陆但没成功时为真
};

const store = createStore((state = initialState, action) => {
switch (action.type) {
case "user-info":
return {
...state,
user: action.user,
};
case "remove-user-info":
return {
...state,
user: null,
};
case "try-login-failed":
return {
...state,
triedLogin: true,
};
default:
return state;
}
}, applyMiddleware(saga));

export default store;

function* getUserInfo(action) {
try {
var res = yield axios.get("/account/userinfo");
yield put({ type: "user-info", user: res.data });
} catch (e) {
yield put({ type: "try-login-failed" });
}
}

function* login(action) {
try {
var res = yield axios.post("/account/login", {
name: action.name,
password: action.password,
});
yield put({ type: "user-info", user: res.data });
} catch (e) {
throw e;
}
}

function* logout(action) {
var res = yield axios.get("/account/logout");
yield put({ type: "remove-user-info" });
}

function* rootSaga() {
yield takeEvery("get-user-info", getUserInfo);
yield takeEvery("login", login);
yield takeEvery("logout", logout);
}

saga.run(rootSaga);

redux-saga

如果按照原始的 redux 工作流程,当组件中产生一个 action 后会直接触发 reducer 修改 state,reducer 又是一个纯函数,也就是不能再 reducer 中进行异步操作;

而往往实际中,组件中发生的 action 后,在进入 reducer 之前需要完成一个异步任务,比如发送 ajax 请求后拿到数据后,再进入 reducer,显然原生的 redux 是不支持这种操作的

这个时候急需一个中间件来处理这种业务场景,目前最优雅的处理方式自然就是 redux-saga

1、Saga 辅助函数

redux-saga 提供了一些辅助函数,用来在一些特定的 action 被发起到 Store 时派生任务,下面我先来讲解两个辅助函数:takeEverytakeLatest

takeEvery 就像一个流水线的洗碗工,过来一个脏盘子就直接执行后面的洗碗函数,一旦你请了这个洗碗工他会一直执行这个工作,绝对不会停止接盘子的监听过程和触发洗盘子函数

例如:每次点击 按钮去 Fetch 获取数据时时,我们发起一个 FETCH_REQUESTED 的 action。 我们想通过启动一个任务从服务器获取一些数据,来处理这个 action,类似于

window.addEventLister('xxx',fn)

当 dispatch xxx 的时候,就会执行 fn 方法,

首先我们创建一个将执行异步 action 的任务(也就是上边的 fn):

// put:你就认为put就等于 dispatch就可以了;

// call:可以理解为实行一个异步函数,是阻塞型的,只有运行完后面的函数,才会继续往下;
// 在这里可以片面的理解为async中的await!但写法直观多了!
import { call, put } from 'redux-saga/effects'

export function* fetchData(action) {
try {
const apiAjax = (params) => fetch(url, params);
const data = yield call(apiAjax);
yield put({type: "FETCH_SUCCEEDED", data});
} catch (error) {
yield put({type: "FETCH_FAILED", error});
}
}

然后在每次 FETCH_REQUESTED action 被发起时启动上面的任务,也就相当于每次触发一个名字为 FETCH_REQUESTED 的 action 就会执行上边的任务,代码如下

import { takeEvery } from 'redux-saga'

function* watchFetchData() {

yield* takeEvery("FETCH_REQUESTED", fetchData)
}

注意:上面的 takeEvery 函数可以使用下面的写法替换

function* watchFetchData() {

while(true){
yield take('FETCH_REQUESTED');
yield fork(fetchData);
}
}

在上面的例子中,takeEvery 允许多个 fetchData 实例同时启动,在某个特定时刻,我们可以启动一个新的 fetchData 任务, 尽管之前还有一个或多个 fetchData 尚未结束

如果我们只想得到最新那个请求的响应(例如,始终显示最新版本的数据),我们可以使用 takeLatest 辅助函数

import { takeLatest } from 'redux-saga'

function* watchFetchData() {
yield* takeLatest('FETCH_REQUESTED', fetchData)
}

和 takeEvery 不同,在任何时刻 takeLatest 只允许执行一个 fetchData 任务,并且这个任务是最后被启动的那个,如果之前已经有一个任务在执行,那之前的这个任务会自动被取消

2、Effect Creators

redux-saga 框架提供了很多创建 effect 的函数,下面我们就来简单的介绍下开发中最常用的几种

  • take(pattern)
  • put(action)
  • call(fn, ...args)
  • fork(fn, ...args)
  • select(selector, ...args)

take(pattern)

take 函数可以理解为监听未来的 action,它创建了一个命令对象,告诉 middleware 等待一个特定的 action, Generator 会暂停,直到一个与 pattern 匹配的 action 被发起,才会继续执行下面的语句,也就是说,take 是一个阻塞的 effect

用法:

function* watchFetchData() {
while(true) {
// 监听一个type为 'FETCH_REQUESTED' 的action的执行,直到等到这个Action被触发,才会接着执行下面的 yield fork(fetchData) 语句
yield take('FETCH_REQUESTED');
yield fork(fetchData);
}
}

put(action)

put 函数是用来发送 action 的 effect,你可以简单的把它理解成为 redux 框架中的 dispatch 函数,当 put 一个 action 后,reducer 中就会计算新的 state 并返回,注意: put 也是阻塞 effect

用法:

export function* toggleItemFlow() {
let list = []
// 发送一个type为 'UPDATE_DATA' 的Action,用来更新数据,参数为 `data:list`
yield put({
type: actionTypes.UPDATE_DATA,
data: list
})
}

call(fn, ...args)

call 函数你可以把它简单的理解为就是可以调用其他函数的函数,它命令 middleware 来调用 fn 函数, args 为函数的参数,注意: fn 函数可以是一个 Generator 函数,也可以是一个返回 Promise 的普通函数,call 函数也是阻塞 effect

用法:

export const delay = ms => new Promise(resolve => setTimeout(resolve, ms))

export function* removeItem() {
try {
// 这里call 函数就调用了 delay 函数,delay 函数为一个返回promise 的函数
return yield call(delay, 500)
} catch (err) {
yield put({type: actionTypes.ERROR})
}
}

fork(fn, ...args)

fork 函数和 call 函数很像,都是用来调用其他函数的,但是 fork 函数是非阻塞函数,也就是说,程序执行完 yield fork(fn, args) 这一行代码后,会立即接着执行下一行代码语句,而不会等待 fn 函数返回结果后,在执行下面的语句

用法:

import { fork } from 'redux-saga/effects'

export default function* rootSaga() {
// 下面的四个 Generator 函数会一次执行,不会阻塞执行
yield fork(addItemFlow)
yield fork(removeItemFlow)
yield fork(toggleItemFlow)
yield fork(modifyItem)
}

select(selector, ...args)

select 函数是用来指示 middleware 调用提供的选择器获取 Store 上的 state 数据,你也可以简单的把它理解为redux 框架中获取 store 上的 state 数据一样的功能store.getState()

用法:

export function* toggleItemFlow() {
// 通过 select effect 来获取 全局 state上的 `getTodoList` 中的 list
let tempList = yield select(state => state.getTodoList.list)
}

react-virtualized(虚拟列表)

react-virtualized 是一个功能非常强大的库,其提供了 GridListTableCollection 以及 Masonry 等 五个主要组件,覆盖了常见场景下的长列表数据渲染。react-virtualized 提供了一个 Playground,如果你对其组件很感兴趣,可以去 playground 体验一下。

本文将着重分析其在虚拟列表上的实现,对于其它组件暂不讨论。

react-virtualized 在虚拟列表上的实现上,支持列表项的动态高度和固定高度,与之相关的两个主要属性有 estimatedRowSizerowHeightrowHeight 用于设置列表项的高度:

  • 可以是一个固定值,如 100,此时列表项是固高的
  • 可以是一个根据列表项索引返回其高度的函数:(index: number): number,此时列表项是动态高度的

如果不知道 rowHeight 的值,则可用 estimatedRowSize 属性给列表项元素一个预估的高度,这样就能依赖预估高度计算列表内容的总高度,并且总高度随着列表项的渲染而渐进调整。这个在列表项是动态高度的场景下很有用,可以初始化内容的总高度以撑开容器元素,使其可在垂直方向滚动。

import axios from "axios";
import { useEffect, useState } from "react";
import { useUserInfo } from "./UserContext";
import { Link } from "react-router-dom";
import { useForceLogin } from "./hooks";
import { List, InfiniteLoader, AutoSizer } from "react-virtualized";

import "react-virtualized/styles.css"; // only needs to be imported once

export default function My() {
// var userInfo = useUserInfo()
var [voteData, setVoteData] = useState({ row: [], count: 10 });
var [votes, setVotes] = useState([]);
var user = useForceLogin();

// useEffect(() => {
// axios.get('/vote/myvotes?startIndex=0&stopIndex=30').then(res => {
// setVotes(res.data.rows)
// setVoteData(res.data)
// })
// }, [user])

if (!votes) {
return <div>loading...</div>;
}

function loadMore({ startIndex, stopIndex }) {
return axios
.get(`/vote/myvotes?startIndex=${startIndex}&stopIndex=${stopIndex}`)
.then((res) => {
setVoteData(res.data); //里面有总条目数
votes.splice(startIndex, 0, ...res.data.rows);
setVotes([...votes]);
})
.catch((e) => {
console.log(e);
});
}

function rowRenderer({ key, index, style }) {
// debugger
if (index >= votes.length) {
return (
<div style={style} key={key}>
loading...
</div>
);
}
return (
<div style={style} key={key}>
<Link to={"/vote/" + votes[index].id}>{votes[index].title}</Link>
</div>
);
}

function isRowLoaded({ index }) {
return !!votes[index];
}

return (
<div>
<InfiniteLoader
isRowLoaded={isRowLoaded}
loadMoreRows={loadMore}
rowCount={voteData.count}
>
{({ onRowsRendered, registerChild }) => (
<List
onRowsRendered={onRowsRendered}
ref={registerChild}
width={300}
height={300}
rowCount={voteData.count}
rowHeight={20}
rowRenderer={rowRenderer}
/>
)}
</InfiniteLoader>
{/* <ul>
{votes.rows.map((vote, idx) => {
return <li key={idx}>
<Link to={"/vote/" + vote.id}>{vote.title}</Link>
</li>
})}
</ul> */}
</div>
);
}

虚拟列表自己实现

这是因为页面中 dom 元素过多,导致页面初始化和滚动列表的时候,浏览器渲染的速度慢。

虚拟列表是啥

本来需要渲染 6000 条数据,但是容器盒子可视范围内只能显示 7 条。虚拟列表就是在 6000 条数据中,截取可视区域中最多容纳的条数 7 条,即页面中只存在 7 个真实的 dom 列表元素。然后监听容器的滚动,实时去更新该 7 条数据。

React 长列表优化?虚拟列表!

列表高度固定

列表滚动的时候,如德芙般纵享丝滑,但是观察 dom 树结构发现,只有 10 条数据。这就是根据可视区域高度和每项高度计算得知的。

一个 container 盒子,包含一个 ListBox 盒子,在 ListBox 盒子里面渲染每一个列表项。

listbox 高度 = 每一项的高度 * 列表项的总条数。使得撑开 container 盒子,产生滚动条

初始化的时候计算可视区需要显示的条数,以及开始索引,结束索引等。

计算条数时,注意要使用 Math.ceil(),而不是 floor()

监听 Container 盒子的滚动事件,滚动时计算开始索引和结束索引。

这就实现了列表的无缝衔接

import React, { memo, useState, useMemo, useCallback, useRef } from "react";
import styled from "styled-components";

const Container = styled.div`
overflow-y: auto;
overflow-x: hidden;
height: ${({ height }) => height};
`;
const ListBox = styled.div`
background-color: pink;
position: relative;
`;
const VirList3 = memo(function ({
list = [],
containerHeight = 800,
ItemBox = <></>,
itemHeight = 50,
...props
}) {
const ContainerRef = useRef();
const [startIndex, setStartIndex] = useState(0);
// 用于撑开Container的盒子,计算其高度
const wraperHeight = useMemo(
function () {
return list.length * itemHeight;
},
[list, itemHeight]
);
// 可视区域最多显示的条数
const limit = useMemo(
function () {
return Math.ceil(containerHeight / itemHeight);
},
[startIndex]
);
// 当前可视区域显示的列表的结束索引
const endIndex = useMemo(
function () {
return Math.min(startIndex + limit, list.length - 1);
},
[startIndex, limit]
);

const handleSrcoll = useCallback(
function (e) {
if (e.target !== ContainerRef.current) return;
const scrollTop = e.target.scrollTop;
let currentIndex = Math.floor(scrollTop / itemHeight);
if (currentIndex !== startIndex) {
setStartIndex(currentIndex);
}
},
[ContainerRef, itemHeight, startIndex]
);

const renderList = useCallback(
function () {
const rows = [];
for (let i = startIndex; i <= endIndex; i++) {
// 渲染每个列表项
rows.push(
<ItemBox
data={i}
key={i}
style={{
width: "100%",
height: itemHeight - 1 + "px",
borderBottom: "1px solid #aaa",
position: "absolute",
top: i * itemHeight + "px",
left: 0,
right: 0,
}}
/>
);
}
return rows;
},
[startIndex, endIndex, ItemBox]
);

return (
<Container
height={containerHeight + "px"}
ref={ContainerRef}
onScroll={handleSrcoll}
>
<ListBox style={{ height: wraperHeight + "px" }}>{renderList()}</ListBox>
</Container>
);
});
export default VirList3;

列表高度不固定

列表项高度不固定的话,那如何计算当前可视区域应该显示的条数呢,如何在滚动的时候,修改首位索引,达到无缝衔接呢?

dom 结构

增加一层 div 包裹列表项:该项目中指的是 Wraper 盒子

整体思路

  • 由于列表项高度不固定,导致显示的条数 limit 等变量无法计算。所以我们预先定义一个默认列表项高度(该高度需要根据自己的项目确定合适的高度)。
  • 使用一个缓存数组存储各个列表项的位置,每个对象包含:索引,每一项的顶部距离 ListBox 容器的距离,每一项底部距离 ListBox 容器的距离,每一项的高度。使用 useState 将该缓存数组进行初始化。
  • 计算 limit:因为每一项的高度不固定,所以需要根据容器滚动实时去计算。使用 useMemo 当作计算属性,依赖缓存数组进行实时更新。
  • ListBox 的高度默认为列表项数乘以默认列表项高度,当缓存数组更新的时候会触发 ListBox 高度重新计算。具体代码在 wraperHeight 位置。
  • getTransform 值:当滚动的时候需要调整 Wraper 盒子的高度,以实现页面滚动时,无缝衔接效果。
  • 在滚动时,重新计算起始索引(使用二分查找),结束索引,limit。
  • 当页面滚动时,缓存数组获取列表项中的自定义属性data-id获取到当前项索引,然后通过计算得到当前项真实的位置。踩坑提示:注意这里要用data-id,不要用当前循环的那个索引。
import React, {
memo,
useState,
useMemo,
useCallback,
useRef,
useEffect,
} from "react";
import styled from "styled-components";

const Container = styled.div`
overflow-y: auto;
height: ${({ height }) => height};
`;
const ListBox = styled.div`
background-color: pink;
position: relative;
`;
const Wraper = styled.div``;
const VirList4 = memo(function ({
list = [],
containerHeight = 800,
ItemBox = <></>,
estimatedItemHeight = 90,
...props
}) {
const ContainerRef = useRef();
const WraperRef = useRef();
const [startIndex, setStartIndex] = useState(0);
const [scrollTop, setScrollTop] = useState(0);

const [positionCache, setPositionCache] = useState(function () {
const positList = [];
list.forEach((_, i) => {
positList[i] = {
index: i,
height: estimatedItemHeight,
top: i * estimatedItemHeight,
bottom: (i + 1) * estimatedItemHeight,
};
});
return positList;
});

const limit = useMemo(
function () {
let sum = 0;
let i = 0;
for (; i < positionCache.length; i++) {
sum += positionCache[i].height;
if (sum >= containerHeight) {
break;
}
}
return i;
},
[positionCache]
);

const endIndex = useMemo(
function () {
return Math.min(startIndex + limit, list.length - 1);
},
[startIndex, limit]
);

const wraperHeight = useMemo(
function () {
let len = positionCache.length;
if (len !== 0) {
return positionCache[len - 1].bottom;
}
return list.length * estimatedItemHeight;
},
[list, positionCache, estimatedItemHeight]
);

useEffect(
function () {
const nodeList = WraperRef.current.childNodes;
const positList = [...positionCache];
let needUpdate = false;
nodeList.forEach((node, i) => {
let newHeight = node.getBoundingClientRect().height;
const nodeID = Number(node.id.split("-")[1]);
const oldHeight = positionCache[nodeID]["height"];
const dValue = oldHeight - newHeight;
if (dValue) {
needUpdate = true;
positList[nodeID].height = node.getBoundingClientRect().height;
positList[nodeID].bottom =
nodeID > 0
? positList[nodeID - 1].bottom + positList[nodeID].height
: positList[nodeID].height;
positList[nodeID].top = nodeID > 0 ? positList[nodeID - 1].bottom : 0;
}
});
if (needUpdate) {
setPositionCache(positList);
}
},
[scrollTop]
);

const getTransform = useCallback(
function () {
return `translate3d(0,${
startIndex >= 1 ? positionCache[startIndex - 1].bottom : 0
}px,0)`;
},
[positionCache, startIndex]
);

const handleSrcoll = useCallback(
function (e) {
if (e.target !== ContainerRef.current) return;
const scrollTop = e.target.scrollTop;
setScrollTop(scrollTop);
const currentStartIndex = getStartIndex(scrollTop);
console.log(currentStartIndex);
if (currentStartIndex !== startIndex) {
setStartIndex(currentStartIndex);
console.log(startIndex + "====--" + limit + "--====" + endIndex);
}
},
[ContainerRef, estimatedItemHeight, startIndex]
);

const renderList = useCallback(
function () {
const rows = [];
for (let i = startIndex; i <= endIndex; i++) {
rows.push(
<ItemBox
data={list[i]}
index={i}
key={i}
style={{
width: "100%",
borderBottom: "1px solid #aaa",
}}
/>
);
}
return rows;
},
[startIndex, endIndex, ItemBox]
);

return (
<Container
height={containerHeight + "px"}
ref={ContainerRef}
onScroll={handleSrcoll}
>
<ListBox style={{ height: wraperHeight + "px" }}>
<Wraper
style={{
transform: getTransform(),
}}
ref={WraperRef}
>
{renderList()}
</Wraper>
</ListBox>
</Container>
);
});

export default VirList4;

登录逻辑

function Login({ history }) {
var usernameRef = useRef();
var passwordRef = useRef();
var userCtx = useContext(UserContext);
var dispatch = useDispatch();

async function login() {
axios
.post("/account/login", {
name: usernameRef.current.value,
password: passwordRef.current.value,
})
.then((res) => {
dispatch({
type: "user-info",
user: res.data,
});
history.go(-1);
})
.catch((e) => {
alert(e.toString());
});
}

return (
<div>
Username: <input type="text" ref={usernameRef} />
Password: <input type="text" ref={passwordRef} />
<button onClick={login}>Login</button>
</div>
);
}

数据库设计

const { Sequelize, DataTypes, Model } = require('sequelize')
const path = require('path')
var __dirname = __dirname ?? '.'
var exports = exports ?? {}
const dbFile = path.join(__dirname, 'db.sqlite3')
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: dbFile,
logging: false,//关闭sql log
});



class User extends Model { }
exports.User = User

User.init({
name: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
password: {
type: DataTypes.STRING,
allowNull: false
},
salt: {
type: DataTypes.STRING,
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
gender: {
type: DataTypes.ENUM('f', 'm'),
allowNull: false,
},
avatar: {
type: DataTypes.STRING,
}
}, {
sequelize, // We need to pass the connection instance
modelName: 'User' // We need to choose the model name
})

class Vote extends Model { }
exports.Vote = Vote
Vote.init({
title: DataTypes.STRING,
desc: DataTypes.STRING,
deadline: DataTypes.DATE,
multiSelect: DataTypes.BOOLEAN,//单选 or 多选
anonymous: DataTypes.BOOLEAN,//匿名投票
restricted: DataTypes.BOOLEAN,//限制传播
}, {
sequelize, // We need to pass the connection instance
modelName: 'Vote' // We need to choose the model name
})

User.hasMany(Vote)
Vote.belongsTo(User)

class Option extends Model { }
exports.Option = Option
Option.init({
content: DataTypes.STRING,
count: DataTypes.INTEGER,
}, {
sequelize, // We need to pass the connection instance
modelName: 'Option', // We need to choose the model name
timestamps: false, // 关闭时间戳
})

//投票表对应许多选项表
Vote.hasMany(Option)
//选项表属于投票表
Option.belongsTo(Vote)

//表的关系
用户表和选项表 多对多
//用户表对选项表多对多
User.belongsToMany(Option, {
through: 'UserVoting',
timestamps: false,
})
//选项表对用户表多对多
Option.belongsToMany(User, {
through: 'UserVoting',
timestamps: false,
})

sequelize.sync()

图片上传

formidable

后端使用

app.use("/uploads", express.static(path.resolve(__dirname, "uploads")));

app.post("/upload", async (req, res) => {
const form = formidable({
multiples: false, //一次只传一个文件
keepExtensions: true, //保留文件的扩展名
uploadDir: path.join(__dirname, "uploads"), //上传路径
});

form.parse(req, async (err, info, files) => {
if (err) {
next(err);
} else {
// res.end('/uploads/' + path.basename(files.file.path))
res.json({
url: "/uploads/" + path.basename(files.file.path),
});
}
});
});
const cookieParser = require("cookie-parser");
app.use(cookieParser("secret"));
// 用来通过cookie从数据库里查询到当前登陆用户的
app.use(async (req, res, next) => {
if (req.signedCookies.user) {
req.user = await User.findOne({
where: {
name: req.signedCookies.user,
},
});
} else {
req.user = null;
}
next();
});

cors 设置

app.use(
cors({
origin: true,
maxAge: 99999999,
credentials: true,
})
);

Context 使用

import { createContext, useContext } from "react";

const UserContext = createContext();
UserContext.displayName = "UserContext";

export default UserContext;

export function useUserInfo() {
var userCtx = useContext(UserContext);
return userCtx.userInfo;
}

然后在 app.js 里面包裹

<UserContext.Provider value={{ userInfo: userInfo }}></UserContext.Provider>

自定义 hooks

import axios from "axios";
import { useState, useCallback, useEffect } from "react";
import { useLocation, useHistory } from "react-router-dom";
import { useSelector, useDispatch } from "react-redux";

export function useInput(init) {
var [value, setValue] = useState(init);
var [checked, setChecked] = useState(init);

var onChange = useCallback(function (e) {
setValue(e.target.value);
setChecked(e.target.checked);
}, []);

return { value, checked, onChange };
}

export function useQuery() {
return new URLSearchParams(useLocation().search);
}

export function useRequest(url) {
var [data, setData] = useState(null);
var [loading, setLoading] = useState(true);

useEffect(() => {
axios.get(url).then((res) => {
setData(res.data);
setLoading(false);
});
}, [url]);

return [data, loading];
}

export function useForceLogin() {
var history = useHistory();
var user = useSelector((state) => state.user);
var tried = useSelector((state) => state.triedLogin);

var dispatch = useDispatch();

useEffect(() => {
(async () => {
if (user) {
return;
}
if (tried) {
history.push("/login");
}
if (!user) {
dispatch({ type: "get-user-info" });
}
})();
}, [user, tried]);

return user;
}

App.js 使用

function App() {
const history = useHistory();
const userInfo = useSelector((state) => state.user);
const dispatch = useDispatch();

async function logout() {
dispatch({ type: "logout" });
}

useEffect(() => {
dispatch({ type: "get-user-info" });
}, []);

return (
<UserContext.Provider value={{ userInfo: userInfo }}>
<div className="App">
{userInfo ? (
<>
<span>欢迎, {userInfo.name}</span>
<Link to="/home">创建</Link>
<Link to="/my">我的</Link>
<button onClick={logout}>登出</button>
</>
) : (
<>
<Link to="/login">登陆</Link>
<Link to="/register">注册</Link>
</>
)}

<Switch>
<Route path="/" exact>
<Redirect to="/home" />
</Route>
<Route path="/home" component={Home} />
<Route path="/login" component={Login} />
<Route path="/register" component={Register} />
<Route path="/create" component={CreateVote} />
<Route path="/my" component={My} />
<Route path="/vote/:id">
<Suspense fallback={"loading..."}>
<ViewVote />
</Suspense>
</Route>
</Switch>
</div>
</UserContext.Provider>
);
}

Suspense 使用

Suspense 让组件遇到异步操作时进入“悬停”状态,等异步操作有结果时再回归正常状态。

异步操作简单归为两类:

  1. 异步加载代码
  2. 异步加载数据

React 16.6 添加了一个 <Suspense> 组件,可以用来在 lazy load 的时候显示加载中的状态。

const ProfilePage = React.lazy(() => import("./ProfilePage")); // Lazy-loaded

// Show a spinner while the profile is loading
<Suspense fallback={<Spinner />}>
<ProfilePage />
</Suspense>;

后来 React 想,这 Suspense 既然能用来等待 lazy load 的 Promise,其实也可以用来等待其他东西,比如请求数据的 Promise,因此就有了 Suspense for Data Fetching 这个特性。

useHistory 使用

从 React Router v5.1.0 开始,新增了 useHistory 钩子(hook),如果是使用 React >16.8.0,编写以下函数组件,使用 useHistory 即可实现编程时页面跳转导航。

示例:

import { useHistory } from "react-router-dom";
function HomeButton() {
const history = useHistory();
function handleClick() {
history.push("/home");
}
return (
<button type="button" onClick={handleClick}>
Go home
</button>
);
}

React Router v4 编程式页面跳转的方式(补充)

如果是 React Router v4,可以使用以下方法:

  • 使用 withRouter 组件
  • 使用<Route>标签
  • 使用 context

1、使用 withRouter 组件

withRouter 组件将注入 history 对象作为该组件的属性。这样,不需要处理 context,可直接访问 push 和 replace 方法。

示例:

import { withRouter } from 'react-router-dom'
const Button = withRouter(({ history }) => (
<button
type='button'
onClick={() => { history.push('/new-location') }}
\>
Click Me!
</button>
))

2、使用<Route>标签

<Route>组件不仅用于匹配位置。 您可以渲染无路径的路由,它始终与当前位置匹配。 <Route>组件传递与 withRouter 相同的属性,因此能够通过 history 的属性访问 history 的方法。

import { Route } from 'react-router-dom'
const Button = () => (
<Route render={({ history}) => (
<button
type='button'
onClick={() => { history.push('/new-location') }}
\>
Click Me!
</button>
)} />
)

3、使用 context

这个方法不推荐,context api 不是很稳定。示例如下:

const Button = (props, context) => (
<button
type='button'
onClick={() => {
context.history.push('/new-location')
}}
\>
Click Me!
</button>
)

Button.contextTypes = {
history: React.PropTypes.shape({
push: React.PropTypes.func.isRequired
})
}

推荐使用方法 1 和 2,实现起来也简单。

data-fetcher

import axios from "axios";

const voteCache = Object.create(null);

function getVote(id) {
return axios.get("/vote/get/" + id);
}

export const voteFetcher = {
read(id) {
if (id in voteCache) {
return voteCache[id];
} else {
throw getVote(id).then((val) => {
voteCache[id] = val.data;
});
}
},
};