技術棧:react + redux + react-router + express + Nginxcss
練習點:html
線上體驗地址:點我跳轉
github 地址 歡迎 star🌟前端
|-- 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 打包入口文件
1.引入<Link> 組件並使用,可是其有默認的樣式(好比下劃線),還要修改其默認樣式node
import <Link> from 'react-router-dom' ... <Link to="/login" className="login-btn"> <span className="login-text">登陸</span> </Link>
2.導入 withRouter 使用 js 方式跳轉mysql
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
來實現 - 該方案無效react
2.利用元素的 scrollTop 屬性,點擊回到頂部按鈕時設置元素的 scrollTop: 0 再添加 transition 屬性來實現 - 沒法對 scrollTop 屬性實現過渡linux
3.該元素的 css 中添加 scroll-behavior: smooth;
點擊回到頂部按鈕時設置元素的 scrollTop: 0 - OKwebpack
hr { border-color: #eaeaea; border: 0; // 默認橫線 border-top: 1px solid #eee; // 畫條灰色橫線 margin-left: 65px; // 這是塊級元素,能夠用 margin 來控制橫線長度 margin-right: 15px; }
.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); } } }
// 假設在加載組件時這樣添加事件處理程序 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 }
小三角就用咱們熟悉的 css 畫三角來畫,若是咱們想給這個 tooltip 外層加一個邊框?能夠再利用一次僞元素來畫一個三角形,其顏色
就是邊框顏色,利用高度差來實現這個邊框效果。ios
&: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
npm install nodemon cross-env --save-dev
git
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 僅與 server(服務啓動)相關,app.js 負責一些其餘的業務,若是以後須要修改,那麼與 server 相關就只須要負責 www 文件便可。
router 中只負責路由的響應與回覆,不負責具體數據的處理(數據庫操做);
controller 只負責數據,傳入參數操做數據庫返回結果,至關於封裝好的數據操做,與路由無關(路由負責調用)
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;`
// 統一執行 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 中存儲一個 userid,服務器根據傳來的 userid 來獲得對應的 username,那麼就須要花費空間來存儲這一映射關係,假設咱們用全局變量來存儲(即存儲在內存中),這就是所謂的 session 了,即 server 端存儲用戶信息。
那麼如今就變成了:
看上去不錯,可是仍然存在一些問題:假設咱們是 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 就是取不到這個值。網上沒找到解決方案因而本身大體地實現了一下這個功能。
簡單來講就是這樣:
僅貼出部分代碼:
// 路由負責解析請求中的數據以及返回響應,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 的設置及發送問題時都會遇到這種坑,這裏記錄下
// 用於自動登陸 export function autoLogin () { return axios({ method: 'get', url: `${BASE_URL}/user/autoLogin`, withCredentials: true // 注意 axios 默認是不攜帶 cookie 的!!!!! }) }
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(); })
咱們來看看登陸的 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 解決了哪些問題?
npm install pm2 -g
經常使用命令:
能夠自定義 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)拆分日誌:
Linux 的 crontab 命令,即定時任務
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