筆者在作一個完整的博客上線項目,包括前臺、後臺、後端接口和服務器配置。本文將詳細介紹使用react全家桶製做的博客後臺管理系統css
該項目是基於react全家桶(React、React-router-dom、redux、styled-components)開發的一套博客後臺管理系統,用於前端小站的管理,主要功能包括遊客瀏覽、文章管理、類別管理、評論通知、推薦設置和用戶管理html
【訪問地址】前端
域名:https://admin.xiaohuochai.ccvue
Github: https://github.com/littlematch0123/blog-adminreact
或者能夠直接掃描二維碼訪問android
採用移動優先的響應式佈局,移動端、桌面端都可適配;字體大小使用em單位,桌面端的文字相應變大;移動端大量使用滑屏操做,桌面端經過光標設置、自定義滾動條、回車肯定等,提高交互體驗git
根據HTML標籤內容模型,使用語義化標籤,儘可能減小標籤層級,儘可能使用React.Fragment來代替divgithub
採用統一的色調處理,除了黑白兩色外,全部頁面共使用了8種顏色,保證了頁面顏色素雅、統一web
使用service worker實現了離線緩存,配置了robots,禁止搜索引擎抓取頁面正則表達式
使用styled-components插件,實現css in JS。全部圖標資源均採用svg格式,並存儲到common/BaseImg組件中,方便管理,圖片資源均上傳到七牛雲圖牀,使用外鏈訪問。最終,html、css、image都使用js管理
沒有引用第三方組件庫,如bootstrap或螞蟻設計,而是本身開發了項目中所需的公共組件。在common目錄下,封裝了頭像、篩選框、全屏、loading、遮罩、搜索框、滑屏、聯動選擇等組件,方便開發
功能組件按照功能(Post、Comment...)而不是角色(controllers、models、views)分類,將展現組件component和容器組件container整合爲一個文件
狀態管理借鑑了vuex的管理模式,action-types、action、reducer、selecter、state整合到每一個模塊目錄的module.js文件下。爲了方便擴展,全部的state都設置了filter字段
使用配置數據,實現了數據和應用分離,配置數據包括API調用地址和顏色值,以常量的形式存儲在constants目錄下
使用esLint規範JS代碼,代碼風格參照airbnb規範,全部命名採用駝峯寫法,公共組件以Base爲前綴,函數大多以get或set爲前綴,事件函數以on爲前綴,異步函數以async爲後綴,布爾值基本以do或is爲前綴
使用styleLint規範CSS代碼,按照佈局類屬性、盒模型屬性、文本類屬性、修飾類屬性的順序編寫代碼,並使用order插件進行校驗
使用react最新版本的方法,包括createRef()、getDerivedStateFromProps生命週期、 React.Fragment
語法糖等
進行了代碼優化,包括減小請求數量(文件合併 、小圖片使用Base6四、使用301而不是302重定向、靜態資源使用強緩存、接口資源使用協商緩存、使用離線緩存、長緩存優化、CSS內聯),減少資源大小(文件壓縮、andriod下使用webp格式圖片、開啓gzip),優化網絡鏈接(使用DNS預解析、使用keep-alive持久鏈接、使用HTTP2管道化鏈接),優化資源加載(優化資源加載位置、圖片懶加載),減小重繪迴流(減小兄弟選擇器、動畫元素硬件渲染、使用函數節流、及時清理環境)
該項目的一個隱藏彩蛋是搖一搖功能,能夠直接搖到前臺頁面,固然也能夠再搖回來
最終優化評分以下所示
功能主要包括遊客瀏覽、評論通知、用戶管理、推薦設置、文章管理和類別管理
【遊客瀏覽】
在沒有管理員賬號的狀況下,能夠點擊遊客瀏覽進入後臺。可是,遊客只有瀏覽權限,沒有操做權限
【評論通知】
有新評論未查看時,右上角快捷菜單上會出現評論通知的按鈕。查看評論後,通知按鈕消失
【用戶管理】
用戶管理包括查看全部用戶信息、查看用戶點贊狀況、查看用戶評論狀況、按用戶名拼音排序、按點贊數排序、按評論數排序以及設置用戶狀態
【推薦管理】
推薦管理包括文章推薦和專題推薦兩類
一、文章推薦
文章推薦的功能包括更改推薦文章、更改背景圖和更改次序
二、專題推薦
專題推薦的功能包括更改推薦專題、更改專題說明和更改次序
【文章管理】
文章管理包括文章篩選、文章搜索、新建文章、編輯文章、刪除文章、設置配圖、查看點贊等功能
一、文章篩選
初始頁顯示所有文章,設置類別後,只顯示篩選後的文章,文章查閱完成後,可返回文章篩選頁
二、文章搜索
初始頁只顯示搜索框,設置搜索詞後,顯示出相關文章,但每次只顯示16篇,下拉刷新後,可繼續顯示。文章查閱完成後,可返回文章搜索頁
三、新建文章
四、編輯文章
五、設置配圖
六、查看點贊和評論並刪除文章
【類別管理】
類別管理包括查看類別、添加類別、編輯類別、刪除類別
src目錄下,包括assets(靜態資源)、common(公共組件)、components(功能組件)、constants(常量配置)、store(redux)和utils(工具方法)這6個目錄
- assets // 存放靜態資源,包括通用CSS和圖片
global.css // 全局CSS
login_bg.jpg // 登陸框背景圖
- common // 存放公共組件
BaseArticle.js // 文章組件
BaseAvatar.js // 頭像組件
...
- components // 存放功能組件
Category // 類別組件
AddCategory.js // 類別添加組件
DeleteCategory.js // 類別刪除組件
UpdateCategory.js // 類別更新組件
Category.js // 類別路由組件
CategoryForm.js // 類別基礎組件
CategoryItem.js // 類別項組件
CategoryItemList.js // 類別列表組件
CategoryRootList.js // 類別根列表組件
module.js //類別狀態管理
... - constants // 存放常量配置 API.js // 存放API調用地址 Colors.js // 存放顏色值 - store // 存放redux index.js - utils // 存放工具方法 async.js // fetch方法 history.js // 路由方法 util.js // 其餘工具方法
【公共組件】
沒有引用第三方組件庫,如bootstrap或螞蟻設計,而是本身開發了項目中所需的公共組件
封裝了文章組件、頭像組件、返回組件、徽章組件、按鈕組件、卡片組件、篩選框組件、全屏組件、圖片組件、輸入框組件、loading組件、遮罩組件、搜索框組件、滑屏組件、多行輸入框組件、標題組件、麪包屑組件、按鈕組組件、反色按鈕組件、自適應按鈕組件、密碼框組件和聯動選擇組件
BaseArticle.js // 文章組件 BaseAvatar.js // 頭像組件 BaseBack.js // 返回組件 BaseBadge.js // 徽章組件 BaseButton.js // 按鈕組件 BaseCard.js // 卡片組件 BaseFilterList.js // 篩選框組件 BaseFullScreen.js // 全屏組件 BaseImg.js // 圖片組件 BaseInput.js // 輸入框組件 BaseLoading.js // loading組件 BaseMask.js // 遮罩組件 BaseSearchBox.js // 搜索框組件 BaseSwipeItem.js // 滑屏組件 BaseTextArea.js // 多行輸入框組件 BaseTitle.js // 標題組件 BreadCrumb.js // 麪包屑組件 ButtonBox.js // 按鈕組組件 ButtonInverted.js // 反色按鈕組件 ButtonWithAutoWidth.js // 自適應按鈕組件 InputPassword.js // 密碼框組件 LinkageSelector.js // 聯動選擇組件
【功能組件】
按照功能來設置目錄,以下所示
彈出框(Alert)
登陸框(Auth)
類別管理(Category)
評論管理(Comment)
主頁(Home)
點贊管理(Like)
文章管理(Post)
七牛傳圖(Qiniu)
推薦設置(Recommend)
頁面尺寸(Size)
用戶管理(User)
【全屏佈局】
使用設置高度的全屏佈局方式,主要經過calc來實現
<section style={{ height: `${wrapHeight}px` }}>
<HomeHeader />
<Inner>
...
</Inner>
<HomeNav />
</section>
const Header = styled.header` height: 50px; ` const Inner = styled.main` height: calc(100% - 100px); background: ${PRIMARY_BG_COLOR}; ` const List = styled.nav` height: 50px; `
【層級管理】
項目的層級z-index,只使用0-3
全屏的彈出框優化級最高,設置爲3;側邊欄設置爲2;頁面元素默認爲0,若有須要,要設置爲1
【全局彈出層】
在入口文件app.js中設置全局的彈出層和loading,全部組件均可以共用
// app.js render() { const { doShowLoading, alertText, hideAlertText } = this.props return ( <React.Fragment> { doShowLoading && <AlertWithLoading /> } { !!alertText && <AlertWithText text={alertText} onExit={hideAlertText} />} <Router history={history} > ... </Router> </React.Fragment> ) }
【路由管理】
react-router-dom第四版採用了動態路由,在組件目錄內,以組件同名文件保存該組件內的路由
// category.js const Category = () => ( <Switch> <Route exact path="/categories" component={CategoryRootList} /> <Route exact path="/categories/:id" component={CategoryItemList} /> <Route path="/categories/:id/add" component={AddCategory} /> <Route path="/categories/:id/update" component={UpdateCategory} /> <Route path="/categories/:id/delete" component={DeleteCategory} /> </Switch> )
【狀態管理】
參照vuex的狀態管理方式,將每一個組件的狀態管理命名爲module.js,保存在當前組件目錄下
import auth from '@/components/Auth/module' import size from '@/components/Size/module' import alert from '@/components/Alert/module' import categories from '@/components/Category/module' import posts from '@/components/Post/PostsModule' import post from '@/components/Post/PostModule' import comments from '@/components/Comment/module' import likes from '@/components/Like/module' import qiniu from '@/components/Qiniu/module' import users from '@/components/User/module' const rootReducer = combineReducers({ auth, size, alert, categories, posts, post, comments, likes, qiniu, users })
每一個模塊的狀態都設置有filter字段,方便擴展
// action-types export const SET_COMMENTS_FILTER = 'SET_COMMENTS_FILTER' // state const initialState = { filter: null, docs: [] } // action export const setCommentsFilter = filter => dispatch => new Promise(resolve => { resolve() dispatch({ type: SET_COMMENTS_FILTER, filter }) }) // reducer const comments = (state = initialState, action) => { switch (action.type) { case SET_COMMENTS_FILTER: return { ...state, filter: action.filter } } export default comments // selector export const getCommentsFilter = state => state.comments.filter
【數據傳遞】
組件間的數據傳遞方式通常有三種,一種是使用react中的函數傳參,另外一種是使用路由的location屬性,還有一種是經過redux
一、函數傳參
// PostRecommendItem
<BaseSearchBox
searchText={title} datas={posts} onInput={this.onInput} onBack={() => { this.setState({ doShowSearchBox: false }) }} /> onInput = data => { this.setState({ doShowSearchBox: false }) const { updatePostAsync, showAlertText } = this.props const { prevData, datas } = this.statethis.setState({ datas: datas.map(t => { if (t.number === data.number) return data return t }) }) ... } // BaseSearchBox <List innerRef={this.scrollRef}> {resultDatas.map(t => <Item key={t._id} onClick={() => { onInput && onInput(t) }}>{t.title}</Item>)} {resultDatas.length >= limitNumber && !doNeedMoreDatas && <ExtendedItem>已經到底了...</ExtendedItem>} </List>
二、location傳遞state
// CommentForm
constructor(props) {
super(props)
const { operate, location } = props if (operate === 'update' && location.state) { const { content } = location.state.comment this.state = { content } } else { this.state = { content: '' } } } // CommentList history.push({ pathname: `${BasePostUrl}/comments/${t._id}/update`, state: { comment: t } })
三、使用redux
//CategoryForm.js
componentDidMount() {
const { operate, match, setCategoriesFilter } = this.props setCategoriesFilter(Number(match.params.id)).then(() => { if (operate === 'update') { const { category } = this.props const { name, description } = category if (name) { this.setState({ name, description }) } else { history.push(`/categories/${getParentNumber(Number(match.params.id))}`) } } }) } const mapStateToProps = state => ({ category: getCategoryByFilter(state) }) export default connect(mapStateToProps, { setCategoriesFilter })(CategoryForm)
【子頁面刷新】
子頁面刷新時,可能會出現得不到從父級傳遞過來的數據的狀況,筆者的處理是跳轉到父級頁面
componentDidMount() { const { operate, location, match } = this.props if (operate === 'update' && !location.state) { history.push(`/posts/${match.params.postId}/comments`) } }
【reselect】
經過reselect來保存狀態,減小狀態查詢,提高性能
export const getRecommendedCategories = createSelector(getCategories, datas => datas.filter(t => t.recommend).sort((a, b) => a.index - b.index))
【promise】
爲action添加Promise,方便狀態改變後的處理
export const setCategoriesFilter = filter => dispatch => new Promise(resolve => { resolve() dispatch({ type: SET_CATEGORIES_FILTER, filter }) })
【組件共用】
因爲編輯和新建組件用到的元素是同樣的,只不過,新建組件時內容爲空,編輯組件時須要添加內容,這時就能夠複用組件
const AddCategory = ({ match }) => <CategoryForm match={match} operate="add" /> const UpdateCategory = ({ match }) => <CategoryForm match={match} operate="update" />
【清理環境】
若是使用addEventListener綁定了事件處理函數,在組件銷燬的時候,要及時清理環境
componentDidMount() { this.scrollRef.current.addEventListener('scroll', throttle(this.onScroll)) } componentWillUnmount() { this.scrollRef.current.removeEventListener('scroll', throttle(this.onScroll)) }
【生命週期函數】
一、使用getDerivedStateFromProps生命週期函數時,若是不設置constructor,會有以下警告
Did not properly initialize state during construction. Expected state to be an object, but it was undefined.
添加空state便可解決
constructor(props) { super(props) this.state = {} }
二、使用componentDidMount生命週期函數時,若是在該函數中直接使用this.setState(),會有以下警告
Do not use setState in componentDidMount react/no-did-mount-set-state
將state設置轉移到then方法,或者另外一個函數中便可
componentDidMount() { this.test() } test() { this.setState({ name: '' }) }
【應用和數據分離】
使用配置數據,實現數據和應用分離,配置數據包括API調用地址和顏色值,以常量的形式存儲在constants目錄下
// API.js let API_HOSTNAME if (process.env.NODE_ENV === 'development') { API_HOSTNAME = '/local' } else { API_HOSTNAME = '/api' } export const BASE_AUTH_URL = `${API_HOSTNAME}/auth/admin` export const BASE_USER_URL = `${API_HOSTNAME}/users` export const BASE_POST_URL = `${API_HOSTNAME}/posts` export const BASE_TOPIC_URL = `${API_HOSTNAME}/topics` export const BASE_CATEGORY_URL = `${API_HOSTNAME}/categories` export const BASE_LIKE_URL = `${API_HOSTNAME}/likes` export const BASE_COMMENT_URL = `${API_HOSTNAME}/comments` export const BASE_RECOMMEND_URL = `${API_HOSTNAME}/recommends` export const BASE_QINIU_URL = `${API_HOSTNAME}/qiniu` export const STATIC = 'https://static.xiaohuochai.site' export const CLIENT_URL = 'https://www.xiaohuochai.cc' // Colors.js export const PRIMARY_COLOR = '#00a8e5' export const DARK_COLOR = '#0066cc' export const ERROR_COLOR = '#f67280' export const PRIMARY_BG_COLOR = '#fafafa' export const TRANSPARENT_BG_COLOR = 'rgba(7, 17, 27, .4)' export const DARK_BG_COLOR = '#f5f5f5' export const PRIMARY_LINE_COLOR = '#eee' export const DARK_LINE_COLOR = '#ebedf0'
【函數節流】
爲觸發頻率較高的函數使用函數節流
/** * 函數節流 * @param {fn} function test(){} * @return {fn} function test(){} */ export const throttle = (fn, wait = 100) => function func(...args) { if (fn.timer) return fn.timer = setTimeout(() => { fn.apply(this, args) fn.timer = null }, wait) }
【登陸設置】
將用戶信息保存到sessionStorage中並檢測,若是不存在,則跳轉到登陸頁面
<Router history={history} > <Switch> <Route path="/login" component={AuthLogin} /> <Route path="/" render={props => { if (sessionStorage.getItem('token') && sessionStorage.getItem('user')) { return <Home {...props} /> } return <Redirect to="/login" /> }} /> </Switch> </Router>
【全角空格佔位】
使用全角空格佔位,從而使文字對齊
<Label htmlFor="username">用戶名:</Label> <Label htmlFor="password"> 密碼:</Label>
【一像素邊框】
將僞元素高度設置爲1px,而後用 transform縮小到原來的一半
div { position: relative; &::after { position: absolute; left: 0; right: 0; height: 1px; transform: scaleY(.5); content: ''; } `
【緩動彈出層】
過渡彈出層有兩種實現方式,包括transition和animation,該項目使用transition的方式實現
<StyledMask className={doShowMenuList ? 'mask-show' : ''} /> <StyledList className={doShowMenuList ? 'transform-show' : ''} />
const StyledList = styled(HomeMenuList)`
transform: translateY(-100%); transition: .2s; ` const StyledMask = styled(BaseMask)` z-index: 2; display: none; ` const MenuBox = styled.div` cursor: pointer; & .transform-show { transform: translateY(0); } & .mask-show { display: block; } `
【圖標管理】
全部的圖標都使用SVG格式,存儲在common/BaseImg.js文件中
// BaseImg.js ... export const Home = props => ( <svg height={24} viewBox="0 0 24 24" width={24} {...props}> <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" /> <path d="M0 0h24v24H0z" fill="none" /> </svg> )
【搜索實現】
處理搜索功能時,須要特別處理正則表達式中的元字符
static getReg(searchText) { return new RegExp(searchText.replace(/[[(){}^$|?*+.\\-]/g, '\\$&'), 'ig') }
若是將間隔符-放在中間,大寫字母,如V會被匹配爲/V
return new RegExp(searchText.replace(/[[(){}^$|?*+.-\\]/g, '\\$&'), 'ig')
此時的-被識別爲範圍間隔符,至關於.到\之間的字符,正好包括了全部的大寫字母,因此。必定要把-放在最後
【滑屏實現】
滑屏主要經過touch事件來實現,通常地,有兩種形式。一種是當前元素滑動,另外一種是其餘元素滑動。該項目採用較簡單的第二種
static checkSwipe(absMove, duration) { const THRESHOLD = 10 const SHORTESTTIME = 300 // 距離大於10,且時間小於300ms,纔算作一次滑動 return Boolean(absMove > THRESHOLD && duration < SHORTESTTIME) } onTouchStart = e => { this.startTime = new Date().getTime() this.startX = e.targetTouches[0].pageX this.startY = e.targetTouches[0].pageY } onTouchEnd = e => { const { pageX, pageY } = e.changedTouches[0] // 若是y軸移動距離大於元素高度,說明手指已經移出元素自己,則取消滑動 if (pageY - this.startY > this.clientHeight) { return false } const moveX = pageX - this.startX const duration = new Date().getTime() - this.startTime // 若是符合滑動要求,且向左滑動,則控制條滑出 if (BaseSwipeItem.checkSwipe(Math.abs(moveX), duration) && moveX < 0) { this.setState({ doShowControlBox: true }) } else { this.setState({ doShowControlBox: false }) } return true }
【密碼框實現】
密碼框的右側通常都有一個小圖標用於顯示密碼
<Wrap className={className} {...rest} > <StyledInput id="password" textIndent={textIndent} color={color} value={value} onChange={onChange} type={doShowPassword ? 'password' : 'text'} /> { doShowPassword ? <Visibility onClick={onChangeStatus} /> : <VisibilityOff onClick={onChangeStatus} /> } </Wrap>
【fetch函數封裝】
該項目是基於create-react-app構建的,自帶fetch功能。封裝fetch函數到utils目錄下的async.js文件中,將loading組件、alert組件整合到fetch函數的整個數據獲取過程當中
import { showLoading, hideLoading, showAlertText, hideAlertText } from '@/components/Alert/module' import { logout } from '@/components/Auth/module' const async = ({ dispatch, url, method, data, headers, success, fail, doHideAlert }) => { // 顯示loading dispatch(showLoading()) let fetchObj = {} if (method) { fetchObj = { method, body: JSON.stringify(data), headers: new Headers({ ...headers, 'Content-Type': 'application/json' }) } } fetch(url, fetchObj).then(res => { // 關閉loading dispatch(hideLoading()) return res.json() }).then(json => { // 成功 if (json.code === 0) { !doHideAlert && dispatch(showAlertText(json.message)) setTimeout(() => { dispatch(hideAlertText()) }, 1000) success && success(json.result) // 自定義錯誤 } else if (json.code === 1) { dispatch(showAlertText(json.message)) // 系統錯誤 } else if (json.code === 2) { dispatch(showAlertText(json.message)) fail && fail(json.err) // 認證失敗 } else if (json.code === 3) { dispatch(showAlertText(json.message)) dispatch(logout) // 權限不足 } else if (json.code === 4) { dispatch(showAlertText(json.message)) } }).catch(() => { dispatch(showAlertText('服務器故障')) }) } export default async
【組件內路由】
若是要在組件內使用路由功能,可封裝utils/history.js文件
// utils/history.js import createBrowserHistory from 'history/createBrowserHistory' const customHistory = createBrowserHistory() export default customHistory
Router中使用history={history},而不是BrowserRouter
// app.js import history from '@/utils/history' <Router history={history} > <Switch> <Route path="/login" component={AuthLogin} /> <Route path="/" render={props => { if (sessionStorage.getItem('token') && sessionStorage.getItem('user')) { return <Home {...props} /> } return <Redirect to="/login" /> }} /> </Switch> </Router>
而後,在組件中引用便可
import history from '@/utils/history' // 跳轉到首頁 history.push('/')
【虛擬鍵盤】
andriod下,虛擬鍵盤會影響可視區域的高度;而IOS下,不會影響
可視區域高度 = document.documentElement.clientHeight - 虛擬鍵盤的高度;
bug重現以下:
因此,要將包含input域的頁面高度設爲固定
在頁面初始化時,獲取頁面高度
// app.js componentDidMount() { const { setWrapSize } = this.props const { clientHeight, clientWidth } = document.documentElement setWrapSize({ clientHeight, clientWidth }) window.addEventListener('orientationchange', this.setSize) }
而後經過行間樣式,將此高度設置到包含input域的頁面上
// BaseFullScreen <Wrap className={className} style={{ height: `${wrapHeight}px` }} {...rest}>{children}</Wrap>
【取消自動大寫】
IOS下,input域會自動大寫首字母,設置autoCapitallize="off"便可
const BaseInput = ({ value, onChange, ...rest }) => <Input {...rest} value={value} onChange={onChange} autoComplete="off" autoCapitalize="off" />
【光標顏色】
默認狀況下,光標顏色與字體顏色color相同,但也能夠經過caret-color屬性來單獨設置
可是,IOS的光標不支持caret-color,與字體顏色無關,默認爲紫藍色。因此,儘可能不要設置藍色或紫色背景,不然光標看不清楚
【頁面放大】
IOS下,input獲取焦點時會放大,meta設置user-scalable=no,可取消放大效果
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, shrink-to-fit=no">
【圓角】
IOS下,input域只顯示底邊框時,會出現底邊圓角效果,設置border-radius:0便可
border-radius:0
【輪廓outline】
android瀏覽器下,input域處於焦點狀態時,默認會有一圈淡黃色的輪廓outline效果
經過設置outline:none可將其去除
outline: none
【點擊背景】
在移動端,點擊可點擊元素時,android下會出現淡藍色背景,IOS下會出現灰色背景
bug重現以下:
能夠經過-webkt-tap-hightlight-color屬性的設置,取消點擊時出現的背景效果
* { -webkit-tap-highlight-color: rgba(0, 0, 0, 0); }
【局部不滾動】
IOS下,可能會出現局部滾動不流暢,甚至局部不滾動的bug
經過在該元素上設置overflow-scrolling屬性爲touch便可解決
div { -webkit-overflow-scrolling: touch; }
【高度無效】
在IOS下,設置height:100%,若是父級的flex值爲1,而沒有設置具體高度,則100%高度設置無效
處理方法是,在父級經過計算來設置具體高度height,如height: calc(100% - 100px)