Introduction
技術棧:react + redux + react-router + express + Nginx
練習點:
- redux 連接
- react-router 路由跳轉
- scss 樣式書寫
- 容器組件與展示組件的設計
- express 腳手架項目結構設計
- 用戶信息持久化(cookie + redis)
- 常見安全問題處理(xss sql 注入 cookie 跨域)
- Promise 封裝數據庫操作
- PM2 進程守護
線上體驗地址:點我跳轉
github 地址 歡迎 star🌟
Show

Design
前端
項目結構
|-- src
|-- api // 所有API請求(axios)
|-- assets // 字體圖標、全局/混合樣式
|-- components // 展示組件 / 作為某個頁面的局部的組件
|-- common // 可復用的組件
|-- home // home 頁面所用到的組件,即 home 頁面由這些組件構成
|-- edit // edit 頁面所用到的組件
|-- pages // 容器組件 / 該組件整體作為一個頁面展示,與 redux 連接並將 store 中的數據傳遞給其子組件
|-- login // 登錄頁
|-- home // 首頁
|-- Home.jsx // react 組件
|-- Home.scss // 該組件的樣式文件
......
|-- store // redux
|-- home // home 頁對應的 store
|-- action-type.js // action 類型
|-- actions.js // action 構造器
|-- index.js // 用於整體導出
|-- reducer.js // 該 module 的 reducer
|-- module2 // 這個文件夾只是為了說明如果有 redux 有新的 module 需要引入就和 home 文件夾下格式一樣
|-- store.js // 合並 reducer,創建 store(全局唯一)並導出,如果需要應用中間件,在這里添加
|-- App.js // 根組件 / 定制路由
|-- index.js // 項目入口 / webpack 打包入口文件
react-router-dom 路由跳轉的幾種方式
1.引入<Link> 組件並使用,但是其有默認的樣式(比如下划線),還要修改其默認樣式
import <Link> from 'react-router-dom'
...
<Link to="/login" className="login-btn">
<span className="login-text">登錄</span>
</Link>
2.導入 withRouter 使用 js 方式跳轉
import { withRouter } from 'react-router-dom'
// 需要對該組件做如下處理(這是與 redux 連接的同時又使用 withRouter 的情況)
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Header))
// js 方法實現路由跳轉
this.props.history.push('/')
// 簡單來說就是通過某種方式(context?)把 history 給傳遞到這個組件了
回到頂部的過渡效果
1.利用 window.scrollTo(xpos, ypos)
方法加上 transition: all linear .2s
來實現 - 該方案無效
2.利用元素的 scrollTop 屬性,點擊回到頂部按鈕時設置元素的 scrollTop: 0 再添加 transition 屬性來實現 - 無法對 scrollTop 屬性實現過渡
3.該元素的 css 中添加 scroll-behavior: smooth;
點擊回到頂部按鈕時設置元素的 scrollTop: 0 - OK
利用 <hr> 畫分割線
hr {
border-color: #eaeaea;
border: 0; // 默認橫線
border-top: 1px solid #eee; // 畫條灰色橫線
margin-left: 65px; // 這是塊級元素,可以用 margin 來控制橫線長度
margin-right: 15px;
}
利用 transform:scaleX() 實現下划線伸縮效果

.menu-item {
@include center;
width: 100px;
color: #969696;
font-weight: 700;
font-size: 16px;
position: relative;
.icon {
margin-right: 5px;
}
// 利用偽元素給這個 item 加 "下划線"
&:after {
content: '';
position: absolute;
width: 100%;
border-bottom: 2px solid #646464;
top: 100%;
transform: scaleX(0);
transition: all linear .2s;
}
// hover 時改變 scaleX
&:hover {
color: #646464;
cursor: pointer;
&:after { // 咋選的?
transform: scaleX(1);
}
}
}
利用 DOM 操作手動添加的事件處理程序要手動移除
// 假設在加載組件時這樣添加事件處理程序
componentDidMount() {
let app = document.getElementsByClassName('App')[0]
app.addEventListener('scroll', this.handleScroll, false)
}
// 就需要這么移除,否則會報內存泄漏,另外注意這里的 this.handleScroll 必須是與上面的 addEventListener 相同的引用
componentWillUnmount() {
let app = document.getElementsByClassName('App')[0]
app.removeEventListener('scroll', this.handleScroll, false)
}
防止無意義的渲染
shouldComponentUpdate(nextProps, nextState) {
// ArticleList 組件是從父組件拿到的 articlelist,發現在內容沒變的情況下頁面向下滾動就會觸發 render 函數
// 投機取巧......
return nextProps.articleList.length !== this.props.articleList.length
}
自己實現一個帶邊框的 tooltip
小三角就用我們熟悉的 css 畫三角來畫,如果我們想給這個 tooltip 外層加一個邊框?可以再利用一次偽元素來畫一個三角形,其顏色
就是邊框顏色,利用高度差來實現這個邊框效果。
&:after {
content: ''; // 記得加 content 才行
width: 0;
height: 0;
border-width: 10px;
border-style: solid;
border-color: #fff transparent transparent transparent;
position: absolute;
top: 100%;
left: 50%;
z-index: 101;
margin-left: -10px;
}
// 如果我想給小三角再加個邊框?
&:before {
content: '';
width: 0;
height: 0;
border-width: 11px;
border-style: solid;
border-color: #f0f0f0 transparent transparent transparent;
position: absolute;
top: calc(100% + 1px); // calc 大法好
left: 50%;
z-index: 100;
margin-left: -11px;
}
后端
項目結構
|--bin
|-- www // 入口文件 / 啟動文件
|-- conf // 配置項
|-- db.js // 數據庫連接配置 / redis 連接配置
|-- controller
|-- blog.js // 處理 blog 路由相關邏輯(將邏輯操作封裝為函數並導出由供路由處理部分使用)
|-- user.js // 處理 user 路由相關邏輯
|-- db
|-- mysql.js // 建立 mysql 連接,將執行 sql 操作封裝為 Promise 並導出
|-- redis.js // 建立 redis 連接,封裝 set、get 操作並導出
|-- middleware
|-- loginCheck.js // 自定義的中間件
|-- model
|-- resModel.js // 封裝響應的格式
|-- routes // 定義相關的路由處理
|-- blog.js // 與博客文章相關的路由處理
|-- user.js // 與用戶注冊 / 登錄相關的路由處理
|-- utils // 工具類
|-- cryp.js // 加密函數
|-- app.js // 規定中間件的引入順序 / 請求的處理順序,整合路由
|-- package.json
使用 nodemon 和 cross-env
npm install nodemon cross-env --save-dev
nodemon 用於熱重啟,就是跟 webpack 的熱更新差不多,保存文件后自動重啟服務。
cross-env 用於配置環境變量。
packages.json 做如下腳本配置:
"scripts": {
"start": "node ./bin/www",
"dev": "cross-env NODE_ENV=dev nodemon ./bin/www",
"prd": "cross-env NODE_ENV=production pm2 start ./bin/www" // pm2 之后會介紹
},
可以通過如下方式獲取環境參數:從而根據環境來修改我們的一些配置(如 mysql redis)
// 配置文件
const env = process.env.NODE_ENV
// mysql 配置, redis 配置
let MYSQL_CONF
let REDIS_CONF
// 開發環境
if (env === 'dev') {
// mysql
MYSQL_CONF = {
...
}
// redis
REDIS_CONF = {
port: 6379,
host: '127.0.0.1'
}
}
// 線上環境
if (env === 'production') {
...
}
文件結構拆分
- 為什么要把 www 和 app.js 分離?
www 僅與 server(服務啟動)相關,app.js 負責一些其他的業務,如果之后需要修改,那么與 server 相關就只需要負責 www 文件即可。
- router 和 controller 為什么要分離?
router 中只負責路由的響應與回復,不負責具體數據的處理(數據庫操作);
controller 只負責數據,傳入參數操作數據庫返回結果,相當於封裝好的數據操作,與路由無關(路由負責調用)
mysql 占位技巧
let sql = `SELECT * FROM blogs WHERE 1 = 1` // 1 = 1的意義?占位,如果 author 和 keyword 都沒有值這樣不會報錯
if (author) {
sql += `AND author='${author}' `
}
if (keyword) {
sql += `AND title LIKE '%${keyword}%' `
}
sql += `ORDER BY createtime DESC;`
將數據庫執行語句封裝為 Promise 對象
// 統一執行 sql 的函數,並封裝為 Promise 對象
function exec (sql) {
const promise = new Promise((resolve, reject) => {
conn.query(sql, (err, result) => {
if (err) {
reject(err)
return
}
resolve(result)
})
})
return promise
}
我們在 controller 層再做一層封裝:
const getArticleList = () => {
const sql = `SELECT * FROM articles`
return exec(sql)
}
在路由處理時這樣使用:
// Home 頁獲取文章列表
router.get('/getPartArticles', (req, res) => {
const result = getArticleList()
return result.then(data => {
res.json(
new SuccessModel(data)
)
})
})
這么做的目的主要是讓回調的順序更為清晰,本來 Promise 就是為了解決回調地獄的問題,當然也可以采用 async / await 的寫法:
// 這是 koa2 的形式,koa2 原生支持 async / await 的寫法
router.post('/login', async (req, res) => {
// 原來做法
// query('select * from im_user', (err, rows) => {
// res.json({
// code: 0,
// msg: '請求成功',
// data: rows
// })
// })
// 現在
const rows = await query('select * from im_user')
res.json({
code: 0,
msg: '請求成功',
data: rows
})
})
登錄驗證(用戶信息持久化)
這里分析一下只使用 cookie 和 cookie 和 session 結合使用的區別,也可以說是分析下為什么會有這樣的技術迭代。
假設我們使用最原始的方法:用戶輸入用戶名和密碼驗證成功后,服務器向客戶端設置 cookie,我們假設這個 cookie 存儲一個 username 字段(顯然這是一個很愚蠢的行為),那么在用戶首次登錄之后他下次再登錄的時候就擁有了這個 cookie,前端可以設置在用戶一打開應用時就向服務器發送一個請求(自動攜帶 cookie),后端就通過檢測 cookie 中的信息就可以使得用戶直接進入登錄狀態了。
整理一下:
- ① 首次登錄擁有了 cookie
- ② 再次登錄通過檢驗 cookie 的存在與否來確定登錄狀態
在 cookie 中直接暴露用戶信息是愚蠢的行為,下面我們來升級一下。
我們在 cookie 中存儲一個 userid,服務器根據傳來的 userid 來得到對應的 username,那么就需要花費空間來存儲這一映射關系,假設我們用全局變量來存儲(即存儲在內存中),這就是所謂的 session 了,即 server 端存儲用戶信息。
那么現在就變成了:
- ① 首次登錄擁有了 cookie,但這次記錄的是 userid
- ② 再次登錄發送 cookie,服務器分析 cookie 並根據存儲的映射關系判斷登錄狀態
看上去不錯,但是仍然存在一些問題:假設我們是 node.js 的一個進程做服務,用戶數量不斷增加,內存將會暴增,而 OS 是會限制一個進程所能使用的最大內存的;另外,假設我為了充分利用 CPU 的多核特性我開個多進程一起來做服務,那么這些進程之間的內存無法共享,即用戶信息無法共享,這就不太妙了。
於是我們可以通過使用 redis 來解決這一問題,redis 不同於 mysql,其數據存放在內存中(雖然昂貴但訪問存快),我們把原先要在各個進程中存儲的全局變量改為統一存儲在 redis 中,這樣就可以做到多進程共享信息(全部通過訪存 redis 來實現)
那么 node.js 中應該怎么寫呢?
本來 express-session 這個中間件可以十分方便地幫我們實現這一需求的,只需要大概如下的配置就可以實現我們上述所說的需求
(向客戶端設置 cookie,將相關信息存儲進 redis),具體的可以參考
這篇文章
const redisClient = require('./db/redis')
const sessionStore = new RedisStore({
client: redisClient
})
app.use(session({
secret: 'WJiol#23123_',
cookie: {
// path: '/', // 默認配置
// httpOnly: true, // 默認配置
maxAge: 24 * 60 * 60 * 1000
},
store: sessionStore
}))
然而使用時卻一直有 bug,簡單來說就是一個路由設置 req.session.xxx 的值后,理論上應該存入了 redis 且設置了相應的 cookie,下次攜帶該 cookie 的請求
到達時,可以直接通過 req.session.xxx 來取值,bug 就是取不到這個值。網上沒找到解決方案於是自己大致地實現了一下這個功能。
簡單來說就是這樣:
- 用戶成功登錄后,設置一個 cookie,我們稱其為 userid,同時將 userid - username 這個鍵值對存入 redis
- 該用戶再次打開應用(如首頁或其他頁面),發起一個自動登錄的請求,該請求會攜帶 cookie.userid,后端檢查 redis 中是否有與這個 userid 相同的鍵,如果有則取出 username,必要的話再利用 username 查詢一些用戶信息並返回給前端;如果沒有則表示該用戶未登錄過。
僅貼出部分代碼:
// 路由負責解析請求中的數據以及返回響應,controller 提供數據庫邏輯操作函數
router.post('/login', function(req, res, next) {
const { username, password } = req.body // 中間件會幫我們把 POST body 中的數據存入 req.body
const result = login(username, password) // 返回的是一個 Promise 對象
return result.then(data => {
if (data.username) { // 如果不成功,data 為空對象
// 設置 session - 登錄之后就在 redis 中存儲了用戶信息
// 登錄成功后給用戶設置一個 cookie 存儲一個 userid
// 然后 redis 中存儲 cookie / username 的鍵值對
const userid = `${Date.now()}_${Math.random()}` // 隨機生成一個 userId 串
set(userid, data.username) // redis 操作
res.cookie('userid', `${userid}`, {expires: new Date(Date.now() + 24 * 60 * 60 * 1000), httpOnly: true}) // path 默認 / domain 默認為 app 的,認為設置 domain 的話要注意一些細節問題
res.json( // res.json 接收一個對象作為參數,返回 JSON 格式的數據
new SuccessModel()
)
return
}
res.json(
new ErrorModel('loginfail')
)
})
})
router.get('/autoLogin', (req, res) => {
const userid = req.cookies.userid
if (userid) {
get(userid).then(data => {
const username = data // 我們拿到的是 username, 然后要利用 username 獲取用戶信息
const result = getUserInfoByUsername(username)
return result.then(userinfo => {
if (userinfo) {
res.json(
new SuccessModel(userinfo)
)
} else {
res.json(
new ErrorModel('獲取用戶信息失敗')
)
}
})
})
} else {
res.json(
new ErrorModel('沒有 cookie')
)
}
})
與 cookie 相關的跨域問題
相信大部分人在初次接觸 cookie 的設置及發送問題時都會遇到這種坑,這里記錄下
- 使用 axios 庫時,AJAX 請求默認不允許攜帶 cookie,需要如下設置:
// 用於自動登錄
export function autoLogin () {
return axios({
method: 'get',
url: `${BASE_URL}/user/autoLogin`,
withCredentials: true // 注意 axios 默認是不攜帶 cookie 的!!!!!
})
}
- server 端的 Access-Control-Allow-Origin 不能為 *,Access-Control-Allow-Credentials 要為 true
app.all('*', function(req, res, next) {
// 注意 cookie 的跨域限制比較嚴格,這里不能使用 *,必須與要發送 cookie 的 Origin 相同,本地測試時如 http://localhost:3000 而且不能指定多個只能指定一個!
// 線上應該是掛載 html 頁面的域名和端口號
res.header("Access-Control-Allow-Origin", "http://localhost:3000")
res.header('Access-Control-Allow-Methods', 'PUT, GET, POST, DELETE, OPTIONS')
res.header('Access-Control-Allow-Credentials', 'true')
res.header("Access-Control-Allow-Headers", "X-Requested-With")
res.header('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept')
next();
})
xss 與 sql 注入防范措施
我們來看看登錄的 sql 語句
const sql = `SELECT username, realname FROM users WHERE username='${username}' AND password='${password}'`
假設我們把 sql 語句改成這樣,那么根本不用輸入密碼就能登錄成功(即用戶輸入用戶名為zhangsan'--
)
SELECT username, realname FROM users WHERE username='zhangsan'--' AND password='123'
如果是這樣就更危險了
SELECT username, realname FROM users WHERE username='zhangsan'; DELETE FROM users;--' AND password='123'
mysql 模塊自帶的 escape 方法可以幫我們解決這個問題
username = escape(username)
password = escape(password)
const sql = `
SELECT username, realname FROM users WHERE username=${username} AND password=${password} // 注意使用了 escape 后不加引號
`
我們來看看 escape 函數處理上述輸入后的輸出:
// before
SELECT username, realname FROM users WHERE username='zhangsan'--' AND password='123'
// after
SELECT username, realname FROM users WHERE username='zhangsan\'--' AND password='123'
理論上來說,所有通過拼接變量執行的 sql 語句都需要做 sql 注入的考慮
下面再說下 xss 防范
npm install xss --save
如果用戶輸入的文章標題或內容是這樣的就屬於 xss 攻擊
<script>alert(document.cookie)</script>
我們只需要這么處理:
let title = xss(ArticleTitle)
然后再把內容存入數據庫即可,該工具會幫我們轉義,即:
& -> &
< -> <
> -> >
" -> "
' -> '
/ -> /
...
密碼加密
考慮如果數據庫被攻破了,如果數據庫中明文存儲用戶的用戶名和密碼,那后果是無法預料的,所以我們還要對用戶的密碼做加密處理。
我們在注冊時,不直接存儲用戶輸入的密碼,我們可以使用一些加密方法(如 md5)將密碼加密后再存入數據庫,下次該用戶登錄時,
仍然輸入同樣的密碼,我們先對該密碼串進行 md5 加密后再進行查詢。這樣就做到了密碼加密。
const crypto = require('crypto') // 自帶庫
// 密匙
const SECRET_KEY = 'wqeW123s_#!@3'
// MD5 加密
function md5(content) {
let md5 = crypto.createHash('md5')
return md5.update(content).digest('hex') // 輸出變為16進制
}
// 加密函數
function genPassword(password) {
const str = `password=${password}&key=${SECRET_KEY}`
return md5(str)
}
PM2 的使用
PM2 解決了哪些問題?
- 服務器穩定性:進程守護,系統崩潰自動重啟,這個很重要!
- 充分利用服務器硬件資源,以提高性能:可以啟動多進程提供服務
- 線上日志記錄:自帶日志記錄功能
npm install pm2 -g
常用命令:
- pm2 start ...: 啟動
- pm2 list: 查看 pm2 進程列表
- pm2 restart <App name> / <id>: 重啟
- pm2 stop <App name> / <id>: 停止
- pm2 info <App name> / <id>
- pm2 log <App name> / <id>
- pm2 monit <App name> / <id>: 監控 CPU / 內存信息
可以自定義 PM2 的配置文件(包括設置進程數量,日志文件目錄等)
{
"apps": {
"name": "pm2-test-server",
"script": "app.js",
"watch": true, // 監聽文件變化自動重啟(開發環境 / 線上環境)
"ignore_watch": [ // 哪些文件變化是不需要監聽的
"node_modules",
"logs"
],
"instances": 4, // 多進程相關 CPU 核數
"error_file": "logs/err.log", // 錯誤日志路徑,未定義會有默認路徑
"out_file": "log/out.log", // console.log 打印的內容
"log_date_format": "YYYY-MM-DD HH:mm:ss", // 日志時間格式,自動添加時間戳
}
}
日志分析技巧
使用 crontab 命令(linux)拆分日志:
- 日志內容會慢慢積累,放在一個文件中不好處理
- 按時間划分日志文件,如 2019-02-10.access.log
Linux 的 crontab 命令,即定時任務
- 設置定時任務,格式:* * * * * command
- 分鍾 小時 月份 日 星期 command 是 shell 腳本
command 需要執行什么?
1.將 access.log 拷貝並重命名為 2019-02-10.access.log
2.清空 access.log 文件,繼續積累日志
我們可以編寫如下腳本:
// copy.sh
cd /Users/Proj/blog-proj
cp access.log $(date + %Y-%m-%d).access.log
echo "" > access.log
// 每天凌晨觸發該 shell 腳本
crontab -e 1 * 0 * * * sh /Users/Proj/blog-Proj/copy.sh