首頁>技術>

技術棧:react + redux + react-router + express + Nginx

練習點:

redux 連線react-router 路由跳轉scss 樣式書寫容器元件與展示元件的設計express 腳手架專案結構設計使用者資訊持久化(cookie + redis)常見安全問題處理(xss sql 注入 cookie 跨域)Promise 封裝資料庫操作PM2 程序守護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 來實現 - 該方案無效

利用 <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_CONFlet 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 函式處理上述輸入後的輸出:

// beforeSELECT username, realname FROM users WHERE username='zhangsan'--' AND password='123'// afterSELECT 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.shcd /Users/Proj/blog-projcp access.log $(date + %Y-%m-%d).access.logecho "" > access.log
// 每天凌晨觸發該 shell 指令碼crontab -e 1 * 0 * * * sh /Users/Proj/blog-Proj/copy.sh

9
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 處理XML資料應用實踐