Koa開源:Fairy-使用 Koa + React 搭建的前后端分離框架
Fairy - 一個前后端分離框架
一個能夠支持前后端分離并支持中間層同構的完整框架,或許現在它還不夠完善,但是我會把構建該框架中遇到的問題都列出來,以方便其他人遇到問題不在需要去到處搜索問題,希望為自己搭建框架的人有一些幫助,文檔也會不斷更新和優化,你可以watch項目隨時看到文檔的更新,也希望最后成為一個完整而又完美的框架
框架名字
為什么叫這個名字?
因為這是一份送給一只名叫Fairy Mo(美人)的貓的禮物 ~
計劃執行
- 路由同步 React-router及Koa-router同步
- 模板同步 View層同步,使用react-dom/server實現
- 數據同步 使用Redux及React-redux實現
- css-modules同步 保證前后端生成的css-modules相同
- webpack熱加載組件優化
怎么安裝
開啟本地數據庫Mysql,并使用phpmyadmin類似的工具在mysql中創建數據庫(名字隨意之后要填寫),之后將mysql中的文件夾sql文件導入數據庫, 最后在server/config/db.json中配置mysql的數據庫名稱和用戶名密碼即可
npm i
npm start
框架優勢
- 路由同步(前后端共用一套路由)
- 模板同步(前后端共用一套模板)
- 數據同步(前后端公用一套數據狀態機)
同構對比之前非同構加載對比, 可以明顯看到白屏時間更少, 頁面總計加載速度更快
非同構 VS 同構
也想從頭到尾構建一個這樣的框架? Well, 我把構建過程盡量詳細的寫下來
架構
對于一個框架, 最重要是架構, 我們如果需要構建一個前后端中間層同構的插件, 就需要在一個文件夾中. 考慮架構時,我為了讓前后端所使用的環境相對獨立, 前端部分可以單獨提取出來進行制作, 也希望后端部分也能更清晰的展現和管理. 我們決定將框架內容分成兩大部分, Cliet 和 Server 兩個文件夾分別保存.對于前端文件夾目錄結構相對比較固定了, 不需要考慮太多東西, 按照官方的推薦目錄即可, 將View, Router和Store區分出來即可. 而服務器端部分, 我們考慮后還是使用最經典的MVC架構, 這樣可以將控制層, 數據層和展示層區分出來, 即利于后臺業務的解耦, 也利于我們之后的維護修改和添加新業務.
目錄結構如下:
├── client 前端開發文件夾
│ ├── assets 前端自測試打包資源生成地址
│ │ └── dist 打包生成的資源文件, 包含js,img,css
│ │ └── js
│ │ └── css
│ │ └── img
│ ├── config webpack配置文件目錄
│ └── src 開發目錄
│ ├── actions redux的action文件存放目錄
│ ├── data 測試數據存放文件
│ ├── dist 資源文件存放目錄
│ │ ├── css
│ │ └── img
│ │ └── js
│ ├── reducers redux的reducers文件存放目錄
│ ├── route 前端路由存放地址
│ ├── store 前端redux狀態控制存放目錄
│ └── view 前端視圖存放目錄
├── public 服務器所使用的前端打包文件夾
│ └── dist
│ ├── css
│ ├── img
│ └── js
└── server 后端開發目錄夾
├── auth 權限驗證目錄 用來存放用戶驗證部分
├── config 后端例如數據庫等配置文件的存放目錄
├── containers 后端控制層 C 層的代碼存放目錄
├── models 后端數據庫控制代碼存放目錄
├── route 后端路由存放目錄
└── view 后端頁面生成外套層存放目錄,由于界面同步, 后端只負責生成頁面時的外套嵌套
前言
為什么要用中間同構?中間同構是什么?
在出現同構之前,我們會讓后端輸出API的json數據, 而前端接收到這些數據以后, 進行封裝和拼裝,這樣會出現一個問題,就是如果業務變更了,那么接口變更,這時候前端和后端人員都需要重新修改字節的代碼和業務邏輯,之前的主要問題如下:
- 1. 前后端需要進行各自的開發, 并以json接口對接
后端用自己的業務實現業務邏輯和json數據的拼接,前端接收到json數據,完成數據到界面的轉換,成本很大
- 2. SEO問題
異步加載的SEO優化問題,沒有服務端渲染,蜘蛛抓取不到數據,無SEO可言
其實理由很簡單,就是為了減少開發成本,并增加整體項目的可維護性, 而且通過使用該技術可以有效減少網頁加載速度,并提供優良的SEO優化能力,還能夠利用Nodejs的高并發能力的特性,讓Nodejs處理它最擅長的方面,從而增加整體架構的負載能力,節約硬件成本,提升項目的的負載能力
一. Nodejs的環境安裝和配置
構建一個這樣的框架 肯定用的是NodeJS環境, 畢竟無論是后端服務器, 代碼打包優化等工作,都是由Nodejs負責制作的
Window: 直接下載官方安裝包安裝即可,請下載最新版本,這樣可以支持新的一些特性,例如async和await等,之后會用到
Mac: 不建議用安裝包安裝 一個是升級不方便,切換版本也不方便,而且卸載非常麻煩,mac使用brew安裝或者nvm來安裝,攻略大家自己搜索吧
二.webpack及babel環境工具的搭建
當完成了Nodejs環境, 我們會遇到一個大坑, 就是需要一些自動化工具的使用, 主要是webpack和babel,webpack我們使用2.0版本,也是最近發布的
Webpack 是當下最熱門的前端資源模塊化管理和打包工具。它可以將許多松散的模塊按照依賴和規則打包成符合生產環境部署的前端資源。還可以將按需加載的模塊進行代碼分隔,等到實際需要的時候再異步加載。通過 loader 的轉換,任何形式的資源都可以視作模塊,比如 CommonJs 模塊、 AMD 模塊、 ES6 模塊、CSS、圖片、 JSON、Coffeescript、 LESS 等。
可以看到官方介紹,更多是將該工具定義為一個模塊化管理和打包工具,也正是這樣的,它在我們前端工作中負責的就是自動打包的功能,它能幫助我們自動合并打包js,刪除重復的js代碼,自動處理一些樣式文件等工作,等于是一個自動化棧,完成各種之前需要手動操作的工作
Babel是一個轉換編譯器,它能將ES6轉換成可以在瀏覽器中運行的代碼。Babel由來自澳大利亞的開發者Sebastian McKenzie創建。他的目標是使Babel可以處理ES6的所有新語法,并為它內置了React JSX擴展及Flow類型注解支持。
在當下瀏覽器標準尚未統一,對ES6和ES7支持性未到時候 我們需要babel自動將我們所使用的ES6,7的新特性進行轉換,并變成我們瀏覽器都兼容的ES5語法,并可以讓你的代碼更規范和整齊.
我當時使用這2個工具時候,webpack覺得配置很麻煩,還要一堆loader(加載器),各種配置,覺得非常麻煩,但是適應了以后就覺得非常簡單了
如何配置webpack2的配置和怎么讓環境支持熱加載
這部分由于是客戶端環境的工具配置, 不會講解的特別細, 大家可以去官方中文網進行閱讀和學習,但是會將一些遇到的坑,和如何配置,我們來看下webpack2的配置都是干什么的,當前環境的配置 Webpack配置文件
為什么要2個配置文件?devServer.js是什么?
開發環境配置文件(webpack.config.dev.js & devServer.js)
嗯很簡單, 一個配置文件(webpack.config.dev.js)是用來開發環境用的, 它支持一些熱加載熱替換功能, 不需要我們在刷洗頁面就可以看到隨時修改的變化, 并且會支持source map的支持,方便我們調試頁面和腳本. 自然需要實現這些需要我們搭配一個服務器一起運行
我們看下代碼片段:
//加載webpack模塊
webpack = require('webpack'),
//加載自動化HTML自動化編譯插件
HtmlWebpackPlugin = require('html-webpack-plugin'),
autoprefixer = require('autoprefixer'),
precss = require('precss'),
postcsseasysprites = require('postcss-easysprites'),
//加載公用組件插件
CommonsChunkPlugin = webpack.optimize.CommonsChunkPlugin
頭部這些主要是一些需要使用到的插件組件, 注釋也寫得很清楚了, 引用了這些插件, 我們才能實現對應的功能 我一個一個解釋下吧
webpack = require('webpack'),
這個不說了
HtmlWebpackPlugin = require('html-webpack-plugin'),
當引用這個組件時,我們就可以完成 自動將模板轉化為HTML頁面并將頁面所使用的css經過webpack打包后的鏈接自動加載在頁面源碼中 ,是不是很方便,我們看下配置
new HtmlWebpackPlugin({
template: 'src/index.html',
//頁面模板的地址, 支持一些特殊的模板, 比如jade, ejs, handlebar等
inject: true,
//文件插入的位置, 可以選擇在 body 還是 head 中
hash: true,
//是否給頁面的資源文件后面增加hash,防止讀取緩存
minify: {
removeComments: true,
collapseWhitespace: false
},
//精簡優化功能 去掉換行之類的
chunks: ['index','vendor','manifest'],
//文件中插入的 entry 名稱,注意必須在 entry 中有對應的申明,或者是使用 CommonsChunkPlugin 提取出來的 chunk. 簡單理解即頁面需要讀取的js文件模塊
filename: 'index.html'
//最終生成的 html 文件名稱,其中可以帶上路徑名
}),
CommonsChunkPlugin = webpack.optimize.CommonsChunkPlugin
當webpack打包時,如果不進行分包工作, 他會打包成一個js文件,名字就是入口你給的名字,比如:
entry: {
index: './src/index.js'
}
如果不使用該組件, 打包完成生成的就只有一個index,js文件,所以我們還是為了將一些公用的包提取出來, 就需要記性分支打包,這樣做為什么? 還是為了利用瀏覽器緩存, 也可以在項目新部署時, 完成只對更新的包進行替換, 而公用那部分不進行替換, 這樣用戶就不需要再下載公用的js, 從減小服務器或者CDN壓力, 對吧 省錢~ 服務器不要錢啊, CDN不收費啊?
怎么用咧? 我們不說直接上代碼
入口配置
entry: {
index:
'./src/index.js',
vendor: [
'react',
'react-dom',
'redux',
'react-redux',
'react-router',
'axios'
]
}
我們可以看到vendor模塊將所有用的一些公用模塊寫在了這里, 為了就是讓這些公共模塊方便之后的插件配置,讓它們單獨打包, index模塊則是我們單頁面應用時所用的腳本,都是我們自己寫得腳本啦~
模塊配置js
new webpack.optimize.CommonsChunkPlugin({
names: [
'vendor', 'manifest'//需要分包的對應的名字
],
filename: jsDir+'[name].js' //配置輸出結構,這里配置的是按路徑和模塊名進行生成
}),
這里為什么多了個manifest? 這個是個啥東西, 說簡單點, 就是webpack2 用來存儲一些關系啦, 鏈接啦之類的東西, 如果不提取這個模塊, 每次打包之后vendor 都會有變化, 就失去了我們替換資源時不替換vendor包的意義了, 對吧~ ~ 所以每次項目更新下,只需要替換index.js和mainifest.js就可以了, 很黑科技吧~ 哈哈 go on go on
autoprefixer = require('autoprefixer'),
//自動加瀏覽器兼容方案, 主要是css3的兼容方案
precss = require('precss'),
//可以讓postCSS支持一些SASS的語法特性
postcsseasysprites = require('postcss-easysprites'),
//支持前端CSS精靈的功能 即背景圖自動拼接和合成為一張圖片, 減少請求
這幾個插件其實都是postCss的插件, PostCSS和LESS, SASS都是CSS預加載器, 主要是為了讓我們更加快捷和簡單的編寫CSS 并讓CSS支持一些編程的特性, 例如循環, 變量等功能 這里我們構建選擇了postCSS, 原因很簡單, 1.非常快 2. 插件可以支持Sass和Less的功能
我們看看webpack是如何處理文件的, webpack采用的是**loader(加載器)**來處理, 用各種loader進行文件的細化處理和特性的執行,看下代碼:
module: {
//加載器配置
rules: [
{
test: /\.css$/,
use: [
{
loader: "style-loader"
}, {
loader: "css-loader",
options: {
modules: true,
camelCase: true,
localIdentName: "[name]_[local]_[hash:base64:3]",
importLoaders: 1,
sourceMap: true
}
}, {
loader: "postcss-loader",
options: {
sourceMap: true,
plugins: () => [
precss(),
autoprefixer({
browsers: ['last 3 version', 'ie >= 10']
}),
postcsseasysprites({imagePath: '../img', spritePath: './assets/dist/img'})
]
}
}
]
}, {
test: /\.css$/,
exclude: [path.resolve(srcDir, cssDir)],
use: [
{
loader: "style-loader"
}, {
loader: "css-loader",
options: {
importLoaders: 1,
sourceMap: true
}
}, {
loader: "postcss-loader",
options: {
sourceMap: true,
plugins: () => [
precss(),
autoprefixer({
browsers: ['last 3 version', 'ie >= 10']
}),
postcsseasysprites({imagePath: '../img', spritePath: './assets/dist/img'})
]
}
}
]
}, {
test: /\.js$/,
exclude: /node_modules/,
use: [
{
loader: "babel-loader",
options: {
presets: ['react-hmre']
}
}
]
}, {
test: /\.(png|jpeg|jpg|gif|svg)$/,
use: [
{
loader: "file-loader",
options: {
name: 'dist/img/[name].[ext]'
}
}
]
}
]
},
非常長 我們拆分來看:
CSS的處理, 具體格式不說了, 直說都是干什么的, 為什么這么做
{
test: /\.css$/,
use: [
{
loader: "style-loader" //用來處理最基礎的css樣式
}, {
loader: "css-loader",
options: {
modules: true, //是否支持css-modules
camelCase: true,//是否支持 -(中缸線)寫法的class,id名稱
localIdentName: "[name]_[local]_[hash:base64:3]",//css-modules的生成格式
importLoaders: 1, // 是否支持css import方法
sourceMap: true //是否生成css的sourceMap, 主要用來方便調試
}
}, {
loader: "postcss-loader", //postCSS加載模塊,可以使用postCSS的插件模塊
options: {
sourceMap: true,
plugins: () => [
precss(), //支持Sass的一些特性
autoprefixer({
browsers: ['last 3 version', 'ie >= 10']
}),//CSS3 自動化兼容方案
postcsseasysprites({imagePath: '../img', spritePath: './assets/dist/img'}) //支持css精靈功能
]
}
}
]
}, {
test: /\.css$/,
exclude: [path.resolve(srcDir, cssDir)],
use: [
{
loader: "style-loader"
}, {
loader: "css-loader",
options: {
importLoaders: 1,
sourceMap: true
}
}, {
loader: "postcss-loader",
options: {
sourceMap: true,
plugins: () => [
precss(),
autoprefixer({
browsers: ['last 3 version', 'ie >= 10']
}),
postcsseasysprites({imagePath: '../img', spritePath: './assets/dist/img'})
]
}
}
]
},
看完代碼, 我們主要說下css-modules, 引用它的主要原因是為了讓樣式可以自動加上標識和hash, 這樣就可以做到讓樣式永遠不會沖突的功能了, 這樣做的好處顯而易見, 就是可以讓團隊一起開發時, 不在糾結樣式名沖突的問題, 而且也可以通過使用css-modules減少樣式的層疊, 減少父級的引用, 這樣的低層級有利于樣式的復用和利用, 讓樣式更加通用和增強復用性.
為什么這里寫了2個css loader模塊, 原因很簡單, 因為為了防止其他插件,模塊的樣式被增加css-modules的變化, 所以需要加另一個loader讓其他不在指定文件夾中的css樣式不在受到css-modules的影響, 所以多加了一句 exclude: [path.resolve(srcDir, cssDir)] , 刪掉了對應css-modules的配置部分, 詳細的見上面的代碼.
我們再看看 輸出的配置
output: {
path: assetsDir,//path代表js文件輸出的路徑
filename: jsDir + '[name].js', //用來配置輸出文件名格式
publicPath: '/' //公共路徑, 用來配置所有資源前面增加的路徑,之后在生成目錄會講解該路徑的具體用途
},
最后我們看下 開發工具 , 通過使用該工具,可以自動加載source-map,方便我們的調試開發, 畢竟壓縮過的代碼是無法進行調試的, 而source-map可以還原之前代碼并指向位置, 這樣方便我們操作
devtool: 'source-map',
因為是開發環境, 所以我們需要一個重要的功能, 就是 react的熱加載 , 什么是熱加載呢? 就是我們在開發中可以不刷新頁面就可以完成頁面的變化, 包括樣式,react腳本的變化, 還有對應redux的狀態控制變化等, 這里我們使用的是 webpack-dev-server 和 react-hot-loader3 , 通過它們的使用, 就可以完成頁面的熱加載和替換功能. 我們看下配置方法:
建立一個devServer.js, 用來執行服務器的運行和具體配置, 并附加上react-hot-loader3插件, code如下:
//加載Node的Path模塊
const path = require('path');
//加載webpack模塊
const webpack = require('webpack');
// const express = require('express');
const WebpackDevServer = require('webpack-dev-server');
//加載webpack配置文件
const config = require('./webpack.config.dev');
//配置及初始化Koa服務器
var creatServer = () => {
//初始化webpack應用
let compiler = webpack(config);
//調用webpack熱加載模塊及對應參數
let app = new WebpackDevServer(webpack(config), {
publicPath: config.output.publicPath, //文件的輸出路徑,由于是都是在內存中執行的, 所以是看不到具體的文件的
hot: true, //是否開啟熱加載功能
historyApiFallback: true,//是否記錄瀏覽器歷史,配合react-router使用
stats: {
colors: true // 用顏色標識
}
});
//調用開啟5000端口用來測試和開發
app.listen(5000, function(err) {
if (err) {
console.log(err);
}
console.log('Listening at localhost:5000');
});
};
//調用創建koa服務器方法
creatServer();
配置文檔含有注釋, 不在詳細介紹, 只說下 webpack-dev-server 的功能, 這個服務器等于一個微型的express或者koa框架, 使用它可以使用nodejs完成一個簡單的本地服務器, 并支持熱替換功能, 主要是檢測webpack打包過程和讓程序支持熱加載, 但是應用了這個插件并不會完成所有熱加載效果, 比如我們在使用redux時, 就會出問題, 因為這個熱替換并不能保留state(狀態), 所以使用時, 每次保存, react組件的狀態就不會保留, 所以需要引入另一個插件 react-hot-loader 來解決這個問題, 我們看下如何使用這個插件, 插件的使用方法很多, 我選擇了一個最簡單的方法來實現,見code
第一步: 在入口文件哪里加上最上面3句話
entry: {
index: [
'react-hot-loader/patch',
'webpack-dev-server/client?http://0.0.0.0:5000',
'webpack/hot/only-dev-server',
'./src/index.js'
]
}
第二部: 增加webpack中的熱更新插件
new webpack.HotModuleReplacementPlugin(),
第三部: 在babel中增加對應的熱加載模塊
我們需要在根目錄中增加一個文件.babelrc,用來配置babel的配置
我們只需要增加一個熱加載插件
{
"plugins": [ "react-hot-loader/babel" ]
}
那么我們之后看下, Babel的配置
{
"presets": ["react", "es2015", "stage-0", "stage-1", "stage-2", "stage-3"],
"plugins": ["transform-class-properties", "transform-es2015-modules-commonjs", "transform-runtime","react-hot-loader/babel"]
}
我們在Babel中使用了很多的轉換器和插件, 安裝也很簡單,我們使用的包有以下這些,具體功能不在闡述了, 大家自己Search吧~
"babel-cli": "^6.23.0",
"babel-core": "^6.6.5",
"babel-eslint": "^6.1.0",
"babel-loader": "^6.2.4",
"babel-plugin-transform-class-properties": "^6.11.5",
"babel-plugin-transform-es2015-modules-commonjs": "^6.23.0",
"babel-plugin-transform-react-jsx": "^6.23.0",
"babel-plugin-transform-require-ignore": "^0.0.2",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-polyfill": "^6.23.0",
"babel-preset-es2015": "^6.3.13",
"babel-preset-react": "^6.3.13",
"babel-preset-react-hmre": "^1.1.1",
"babel-preset-stage-0": "^6.22.0",
"babel-preset-stage-1": "^6.22.0",
"babel-preset-stage-2": "^6.13.0",
"babel-preset-stage-3": "^6.22.0",
"babel-register": "^6.23.0",
"babel-runtime": "^6.23.0",
最后我們看下 打包生成的webpack配置文件
ExtractTextPlugin = require('extract-text-webpack-plugin'),
webpack在打包代碼時,可以看到樣式直接生成在頁面的, 所以我們如果想讓這些樣式單獨為一個文件引用時, 就需要用這個的插件, 當使用這個插件的時候, 就可以讓頁面以link模式引用css了, 在一些比較大的樣式時 還是讓css樣式存儲在瀏覽器cache里面比較能減輕服務器數據吞吐壓力,配置如下:
new ExtractTextPlugin('dist/css/style.css'),
這里我們將所有樣式壓縮為一個style.css文件,當然,也可以實現分開打包
new ExtractTextPlugin(cssDir + '[name].css'),
//加載JS模塊壓縮編譯插件
UglifyJsPlugin = webpack.optimize.UglifyJsPlugin,
加載壓縮模塊, 可以將js壓縮為最精簡的代碼, 大幅度減小生成的文件大小, 配置如下:
new UglifyJsPlugin({
// 最緊湊的輸出
beautify: false,
// 刪除所有的注釋
comments: false,
compress: {
// 在UglifyJs刪除沒有用到的代碼時不輸出警告
warnings: false,
// 刪除所有的 `console` 語句
// 還可以兼容ie瀏覽器
drop_console: true,
// 內嵌定義了但是只用到一次的變量
collapse_vars: true,
// 提取出出現多次但是沒有定義成變量去引用的靜態值
reduce_vars: true,
}
})
OK, 我們完成了webpack和Babel的配置, 就可以開始開發了, 這部分主要還是資料的稀缺,現在有了中文官方站好一些, 之前很多配置并不是很找, 看了后希望大家能夠明白這些配置是干什么的而不只是按照別人的配置完成即可.
三.react的引用
react作為實現的核心框架,主要的黑科技也是靠它給出的一些方法來實現的,我們先看下react的生命周期
ReactJS的生命周期
ReactJS的生命周期可以分為三個階段來看: 實例化、存在期、銷毀期
實例化
首次實例化
- getDefaultProps
- getInitialState
- componentWillMount
- render
- componentDidMount
實例化之后更新,這一過程和上面一樣,但沒有getDefaultProps這個過程 簡單記憶:props => state => mount => render => mounted
存在期
組件已經存在,狀態發生改變時
- componetWillReceiveProps
- shouldComponentUpdate
- ComponentWillUpdate
- render
- componentDidUpdate
簡單記憶:receiveProps => shouldUpdate => update => render => updated
銷毀期
componentWillUnmount
生命周期中10個API的作用說明
-
getDefaultProps 作用于組件類,只調用一次,返回對象用于設置默認的props,對于引用值,會在實例中共享
-
getInitialState 作用于組件實例,在實例創建時調用一次,用于初始化每個實例的state,此時可以訪問this.props
-
componentWillMount 在完成首次渲染之前調用,此時可以修改組件的state
-
render 必選方法,創建虛擬DOM,該方法具有特殊規則:
只能通過this.props 和this.state訪問數據
可以返回null、false或任何React組件
只能出現一個頂級組件,數組不可以
不能改變組件的狀態
不能修改DOM
-
componentDidMount 真實的DOM被渲染出來后調用,可以在此方法中通過 this.getDOMNode()訪問真實的DOM元素。此時可以使用其它類庫操作DOM。服務端不會被調用
-
componetWillReceiveProps 組件在接收到新的props時調用,并將其作為參數nextProps使用,此時可以更改組件的props及state
-
shouldComponentUpdate 組件是否應當渲染新的props或state,返回false表示跳過后續的生命周期方法,通常不需要使用以避免出現bug。在出現應用性能瓶頸時,是一個可以優化的點。
-
componetWillUpdate 接收新props或state后,進行渲染之前調用,此時不允許更新props或state
-
componetDidUpdate 完成渲染新的props或state之后調用 ,此時可以訪問DOM元素。
-
componetWillUnmount 組件被移除之前調用,可以用于做一些清理工作,在componentDidMount方法中添加的所有任務都需要在該方法中撤銷,比如創建的定時器或添加的事件監聽器。
var React = require("react");
var ReactDOM = require("react-dom");
var NewView = React.createClass({
//1.創建階段
getDefaultProps:function() {
console.log("getDefaultProps");
return {};
},
//2.實例化階段
getInitialState:function() {
console.log("getInitialState");
return {
num:1
};
},
//render之前調用,業務邏輯都應該放在這里,如對state的操作等
componentWillMount:function() {
console.log("componentWillMount");
},
//渲染并返回一個虛擬DOM
render:function() {
console.log("render");
return(
<div>
hello <strong> {this.props.name} </strong>
</div>
);
},
//該方法發生在render方法之后。在該方法中,ReactJS會使用render生成返回的虛擬DOM對象來創建真實的DOM結構
componentDidMount:function() {
console.log("componentDidMount");
},
//3.更新階段
componentWillReceiveProps:function() {
console.log("componentWillReceiveProps");
},
//是否需要更新
shouldComponentUpdate:function() {
console.log("shouldComponentUpdate");
return true;
},
//將要更新 不可以在該方法中更新state和props
componentWillUpdate:function() {
console.log("componentWillUpdate");
},
//更新完畢
componentDidUpdate:function() {
console.log("componentDidUpdate");
},
//4.銷毀階段
componentWillUnmount:function() {
console.log("componentWillUnmount");
},
// 處理點擊事件
handleAddNumber:function() {
this.setProps({name:"newName"});
}
});
ReactDOM.render(<NewView name="ReactJS"></NewView>, document.body);
講一下所謂的React同構的黑科技吧
官方呢 給我們提供了2個方法用來讓服務器進行渲染頁面
-
React.renderToString是把 React 元素轉成一個 HTML 字符串,因為服務端渲染已經標識了 reactid,所以在瀏覽器端再次渲染,React 只是做事件綁定,而不會將所有的 DOM 樹重新渲染,這樣能帶來高性能的頁面首次加載!同構黑魔法主要從這個 API 而來。
-
React.renderToStaticMarkup這個 API 相當于一個簡化版的 renderToString,如果你的應用基本上是靜態文本,建議用這個方法,少了一大批的 reactid,DOM 樹自然精簡了,在 IO 流傳輸上節省一部分流量。
當我們使用renderToString這個方法時候 后臺渲染時會生成一段帶標識的HTML字符串,當前端頁面讀取到JS時會判斷如果HMTl字符串帶標識,那么就不在渲染頁面了,而是只綁定事件,節約了react腳本渲染的工作.
所以用戶刷新頁面時,會由后端進行渲染,發送HTML字符串到前端進行實踐綁定,如果用戶是在react內部點擊切換鏈接,這時候是由react來進行渲染頁面和填充頁面.所以在不刷新頁面時候,可以秒切換頁面. 在用戶刷新的時候,也不在需要等待JS加載完成才能顯示界面,而是直接顯示界面效果.這個就是同構的最大優勢之一
理解了實現的原理, 我們還需要看下如何做 前后端界面同步
我們看下如何實現
我們的界面層非常簡單, 我們在client/view文件夾中創建一個文件, 用來寫react的組件, 代碼如下:
"use strict";
import React, {Component} from 'react';
import ReactDOM, {render} from 'react-dom';
import Nav from '../view/nav.js';
import LoginForm from '../view/components/login_form';
import logo_en from '../dist/img/text_logo.png';
import logo_cn from '../dist/img/text_logo_cn.png';
import '../dist/css/reset.css';
import Login from '../dist/css/login.css';
import Style from '../dist/css/style.css';
class App extends Component {
constructor(props) {
super(props);
//初始化方法, 繼承父級props方法
}
render() {
//將HTML代碼結構保存在內存中 然后渲染一段HTML代碼
return (
<div>
<Nav/>
<div className={Login.banner}>
<p className={Login.text_logo}>
<img width="233" src={logo_en}/>
</p>
<p className={Login.text_logo_cn}>
<img width="58" src={logo_cn}/>
</p>
</div>
<LoginForm/>
<div className={Login.form_reg}>
還沒有賬號?
<a href="#">立即注冊 ListenLite</a>
</div>
</div>
);
}
};
export default App;
上面方法很簡單,哪如何后端使用對應方法使用服務器渲染呢?一樣非常簡單,我們看代碼:
"use strict";
import React from 'react';
import {renderToString, renderToStaticMarkup} from 'react-dom/server';
import {match, RouterContext} from 'react-router';
import {layout} from '../view/layout.js';
import {Provider} from 'react-redux';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import passport from 'koa-passport';
import routes from '../../client/src/route/router.js';
import configureStore from '../../client/src/store/store.js';
import db from '../config/db.js';
import common from '../../common.json';
const User = db.User;
//get page and switch json and html
export async function index(ctx, next) {
console.log(ctx.state.user, ctx.isAuthenticated());
if (ctx.isAuthenticated()) {
ctx.redirect('/');
}
switch (ctx.accepts("json", "html")) {
case "html":
{
match({
routes,
location: ctx.url
}, (error, redirectLocation, renderProps) => {
if (error) {
console.log(500)
} else if (redirectLocation) {
console.log(302)
} else if (renderProps) {
//iinit store
let loginStore = {
user: {
logined: ctx.isAuthenticated()
}
};
const store = configureStore(loginStore);
ctx.body = layout(renderToString(
<Provider store={store}>
<RouterContext {...renderProps}/>
</Provider>
), store.getState());
} else {
console.log(404);
}
})
}
break;
case "json":
{
let callBackData = {
'status': 200,
'message': '這個是登錄頁',
'data': {}
};
ctx.body = callBackData;
}
break;
default:
{
// allow json and html only
ctx.throw(406, "allow json and html only");
return;
}
}
};
首先在server/containers文件夾中創建一個對應上面頁面的控制器, 然后通過引入需要的方法
import {renderToString, renderToStaticMarkup} from 'react-dom/server';
這時候我們就可以在這里使用后臺渲染功能了,我們可以看到, 在增加了渲染這部分內容, 我們可以直接將react的部分直接渲染為HTML代碼串, 這里不詳細闡述,之后會對這里綜合其他同構部分詳細解釋, 這里只需要理解實現方法即可
renderToString(
<Provider store={store}>
<RouterContext {...renderProps}/>
</Provider>
), store.getState()
生成的字符串如下
<div data-reactroot="" data-reactid="1" data-react-checksum="978259924"><ul class="style_nav_2Lm" data-reactid="2"><li class="style_fl_10U" data-reactid="3"><a href
="/" data-reactid="4">首 頁</a></li><li class="style_fl_10U" data-reactid="5"><a href="/404" data-reactid="6">串 流</a></li><li data-reactid="7"><a href="/" data-reactid="8"><i
class="style_logo_2Hq" data-reactid="9"></i></a></li><li class="style_login_visable_2GR" data-reactid="10"><img src="/dist/img/user_1.png" data-reactid="11"/><dl data-reactid="1
2"><a href="/" data-reactid="13"><dt data-reactid="14">我的主頁</dt></a><a href="/" data-reactid="15"><dt data-reactid="16">我要上傳</dt></a><a href="/logout" data-reactid="17">
<dt data-reactid="18">退出</dt></a></dl></li><li class="style_fr_Bxu" data-reactid="19"><a href="/reg" data-reactid="20"><b data-reactid="21">注 冊</b></a></li><li class="style_
fr_Bxu" data-reactid="22"><a href="/login" data-reactid="23">登 錄</a></li></ul><div class="login_banner_eub" data-reactid="24"><p class="login_text_logo_3fN" data-reactid="25">
<img width="233" src="/dist/img/text_logo.png" data-reactid="26"/></p><p class="login_text_logo_cn_iYZ" data-reactid="27"><img width="58" src="/dist/img/text_logo_cn.png" data-r
eactid="28"/></p></div><form data-reactid="29"><div class="login_tips_1nU" data-reactid="30"></div><ul class="login_form_HMj" data-reactid="31"><li data-reactid="32"><i class="l
ogin_segmentation_eZc" data-reactid="33"></i></li><li data-reactid="34"><b data-reactid="35">登錄到 ListenLite</b></li><li class="login_form_border_3hw" data-reactid="36"><input
type="text" name="username" value="" placeholder="用戶名 / 郵箱" data-reactid="37"/></li><li class="login_form_pw_2rP" data-reactid="38"><input type="password" name="password"
value="" placeholder="密碼" data-reactid="39"/></li><li data-reactid="40"><input type="checkbox" name="remmberPw" value="" class="login_remmber_input_28B" id="remmberPw" data-re
actid="41"/><label for="remmberPw" class="login_remmber_pw__H2" data-reactid="42">記住密碼</label></li><li data-reactid="43"><button class="login_form_submit_2A1" disabled="" ty
pe="submit" data-reactid="44">登錄</button></li></ul></form><div class="login_form_reg_32l" data-reactid="45"><!-- react-text: 46 -->還沒有賬號?<!-- /react-text --><a href="#" d
ata-reactid="47">立即注冊 ListenLite</a></div></div>
這里我們可以看到, 我們這里直接渲染了頁面組件, 但是并沒有客戶端中的index.js的外套層, 我們需在后端寫一個document的外套用來包裹這些生成的代碼,所以我們可以再server/view 中看到一個layout.js文件
'use strict';
import common from '../../common.json';
exports.layout = function(content, data) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charSet='utf-8'/>
<meta httpEquiv='X-UA-Compatible' content='IE=edge'/>
<meta name='renderer' content='webkit'/>
<meta name='keywords' content='demo'/>
<meta name='description' content='demo'/>
<meta name='viewport' content='width=device-width, initial-scale=1'/>
<link rel="stylesheet" href="/dist/css/style.css">
</head>
<body>
<div id="root"><div>${content}</div></div>
<script>
window.__REDUX_DATA__ = ${JSON.stringify(data)};
</script>
<script src="${common.publicPath}dist/js/manifest.js"></script>
<script src="${common.publicPath}dist/js/vendor.js"></script>
<script src="${common.publicPath}dist/js/index.js"></script>
</body>
</html>
`;
};
這里很簡單 就是將生成的內容填充到這個外套中
四.前端路由和后端路由的同步
當我們明白了React的生命周期和了解了服務器如何使用官方的2個方法實現服務器渲染功能, 我們看下如何架構前后端公用的路由
首先了解下React-router是什么?看下官方介紹
React Router 是完整的 React 路由解決方案
React Router 保持 UI 與 URL 同步。它擁有簡單的 API 與強大的功能例如代碼緩沖加載、動態路由匹配、以及建立正確的位置過渡處理。你第一個念頭想到的應該是 URL,而不是事后再想起。
簡單的說, 就是以前我們的路由控制, 例如頁面的跳轉等都是由后臺控制的, 瀏覽器發送請求給后臺服務器, 然后后臺反饋內容, 現在由react-router接管了, 跳轉放在了前端來執行, 為什么能實現, 正式因為HTML的新特性 History API
HTML5 新增的歷史記錄 API 可以實現無刷新更改地址欄鏈接,配合 AJAX 可以做到無刷新跳轉。
簡單來說:假設當前頁面為renfei.org/,那么執行下面的 JavaScript 語句:
window.history.pushState(null, null, "/profile/");
之后,地址欄的地址就會變成renfei.org/profile/,但同時瀏覽器不會刷新頁面,甚至不會檢測目標頁面是否存在。
那我們使用react-router在中間同構中做什么? 當然是為了實現前后端的路由同步而做的
- 在用戶第一次訪問頁面時,由服務端路由處理,輸出相關頁面內容
- 客戶端用戶點擊鏈接跳轉,由客戶端路由處理,渲染相關組件并展示
- 用戶在前端跳轉后刷新頁面,此時被服務端路由截獲,并由服務端處理渲染并返回* 頁面內容
我們看下code:
前端路由的設置如下:
const Routers = (
<Router history={browserHistory}>
<Route path="/" component={Home}/>
<Route path="/user" component={User}/>
<Route path="/login" component={Login}/>
<Route path="/reg" component={Reg}/>
<Route path="/logout" component={Logout}/>
<Route path="*" component={Page404}/>
</Router>
export default Routers;
);
可以看到我們使用的切換方法是browserHistory, 既HTML5的新特性, 但是對瀏覽器有要求, IE6-8并不支持, 還有一個是hashHistory, 他們的區別是hash的方式會在鏈接中增加 site.com/#/index的形式展現, 這是為了讓瀏覽器的歷史可以記住每次切換的頁面, 也是用了錨點的特性
再看下服務器端的配置代碼段:
const router = new Router();
//Index page route
router.get('/', require('../containers/index.js').index);
//404 page route
router.get('/user', require('../containers/user.js').index);
router.get('/get_user_info', require('../containers/user.js').getUserInfo);
//User page route
router.get('/404', require('../containers/404.js').index);
//Login page route
router.get('/login', require('../containers/login.js').index);
router.post('/login', require('../containers/login.js').login);
router.get('/logout', require('../containers/login.js').logout);
//Reg page route
router.get('/reg', require('../containers/reg.js').index);
router.post('/reg_user', require('../containers/reg.js').reg);
router.post('/vaildate_user', require('../containers/reg.js').vaildate_user);
router.post('/vaildate_email', require('../containers/reg.js').vaildate_email);
//set a router
module.exports = router.routes()
我們的服務器使用的是koa2, 所以附帶的路由也是對應的koa-router, 由于后臺架構是MVC結構,我們看下路由讀取的控制器層的代碼段
import routes from '../../client/src/route/router.js';
export async function index(ctx,next) {
console.log(ctx.state.user,ctx.isAuthenticated());
switch (ctx.accepts("json", "html")) {
case "html":
{
match({
routes,
location: ctx.url
}, (error, redirectLocation, renderProps) => {
if (error) {
console.log(500)
} else if (redirectLocation) {
console.log(302)
} else if (renderProps) {
//iinit store
let loginStore = {user:{logined:ctx.isAuthenticated()}};
const store = configureStore(loginStore);
console.log(store.getState());
ctx.body = layout(renderToString(
<Provider store={store}>
<RouterContext {...renderProps}/>
</Provider>
), store.getState());
} else {
console.log(404);
}
})
}
break;
case "json":
{
let callBackData = {
'status': 200,
'message': '這個是主頁',
'data': {}
};
ctx.body = callBackData;
}
break;
default:
{
// allow json and html only
ctx.throw(406, "allow json and html only");
return;
}
}
};
我們看到這里使用了react-router的match方法, 這個方法可以自動讀取前端的路由文件,并通過匹配該路徑讀取的模塊反饋模塊代碼, 并通過react服務器渲染進行直出
這里可以看到路由的設計, 在后端使用了koa2, 所以可以對請求的類型進行判斷,這樣充分利用了鏈接的優勢, 可以請求同一個地址, 由于請求類型的不同, 判斷是html還是json,反饋不同的數據結構, 這樣就做到了路由的富應用
五.前端狀態控制及后端狀態控制的同步
什么是redux
Redux 提供了一套類似 Flux 的單向數據流,整個應用只維護一個 Store,以及面向函數式的特性讓它對服務器端渲染支持很友好。
官方的建議呢就是, 如果不需要使用就不使用, 但是我在開發使用中, 發現當應用年開發復雜時, 使用了Redux, 就會發現是真的好用. 正如我們知道, 當我們需要修改react狀態或者界面時,不是直接操作DOM結構,而已是操作state,從而達到更新DOM的方式 , 但是一旦程序變得復雜, 就無法在進行維護和修改了, 會非常復雜. 而使用了Redux, 由于所有組件狀態放在最頂層上統一控制, 從而減少了各個組件的狀態交互, 減少了程序的耦合, 增加了程序的可維護性.
在剛開始使用時, redux確實很難理解, 尤其是他的Flux架構, 但是當你仔細看完官方文檔就明白了, 其實是很簡單的對數據處理, 這里所有數據狀態處理都由action和reducer來操作,其他地方不允許操作數據, 從而保證了數據的一致性. 并可以實現對各種狀態的保存, 從而可以回到之前的任意狀態, 這對這些復雜的應用, 甚至是一些游戲應用來說, 真的是非常爽快的事情.
我們看下如何使用redux和react-redux實現狀態的統一
服務端綁定入口頁面代碼:
let store = configureStore(window.__REDUX_DATA__);
const renderIndex = () => {
render((
<div>
<Provider store={store}> {*這里添加一個Provider外套, 使react頂層組件用來保存store狀態,用來統一管理所有子組件的狀態管理*}
{routes}
</Provider>
</div>
), document.getElementById('root'))
};
renderIndex();
store.subscribe(renderIndex);
{*這里為組件綁定監聽事件, 當狀態改變時就會修改統一的store*}
服務器端為了實現狀態的同步, 并可以在用戶刷新頁面時, 保證頁面中讀取的狀態是上一次最新的狀態, 在這里需要使用客戶端的創建方法記性store的創建
server/containers/login.js
//引用客戶端創建初始store方法
import configureStore from '../../client/src/store/store.js';
let loginStore = {
user: {
logined: ctx.isAuthenticated() ///初始從服務器中讀取用戶登錄狀態,并保存為一個狀態
}
};
const store = configureStore(loginStore);
//通過客戶端方法將初始state傳遞到前端頁面
ctx.body = layout(renderToString(
<Provider store={store}>
<RouterContext {...renderProps}/>
</Provider>
), store.getState());
最后我們通過將該狀態傳遞個一個window對象存儲, 從而直出到前端頁面, 并讀取狀態生成對應的界面效果, 見代碼:
server/view/layout.js
<script>
window.__REDUX_DATA__ = ${JSON.stringify(data)};
</script>
六.服務器的選擇 - koa2
至此, 我們基本明白了三大同構的用處, 明白了為什么要這樣做和怎么做, 我們看下服務器端的架構和使用的一些組件
在服務器框架這邊使用, 在express和koa中最終選擇了koa2, 為什么選它, 原因很簡單, 更輕巧的架構, 更好的中間件機制和強力的性能, 而且也使用了ES6的標準編寫, 為我使用ES6的新特性都非常友好.
首先是啟動一個服務器, 我們在根目錄創建一個app.js文件,然后寫上對應的code就會創建一個koa的服務器
const Koa = require('koa');
const app = new Koa();
// response
app.use(ctx => {
ctx.body = 'Hello Koa';
});
app.listen(3000);
其他koa的相關文檔, 請查閱官方的中文文檔, 這里列一下使用的各種中間件
七.一些koa中間件的介紹和用途
router = require('koa-router')(),
koa的必備中間件, 通過使用該組件, 可以自己在服務器端進行后端路由的設置, 通過設置路由, 完成不同請求(GET,POST,DELETE,PUT等)的服務器狀態, 返回請求body中的內容的設置等
logger = require('koa-logger'),
koa的服務器記錄插件,可以輸出各種請求報錯等信息的輸出, 主要用來調試和監控服務器狀態
bodyParser = require('koa-bodyparser')
這個插件需要說一下, 我在使用表單請求時, koa無法拿到對應的表單信息, 所以需要引用這組件用來解析body的中間件,比方說你通過post來傳遞表單,json數據,或者上傳文件,在koa中是不容易獲取的,通過koa-bodyparser解析之后,在koa中this.body就能直接獲取到數據。
數據庫方面操作方面使用了Sequelize, 可以對多種數據庫進行操作, 并且使用了類似MongoDB一樣的操作方法, 使用起來非常便捷,詳細見 server/models
八.用戶權限驗證 passport
在身份驗證方面, 我們選擇了Nodejs 最常用的權限驗證組件, 這個組件還支持OAuth , OAuth2 及OpenID等標準的登錄.
開發日記
當開發中遇到的問題,我會列在下面,以方便自己查詢和其他人進行相同問題的修改和修復
React使用中遇到的相關問題
問題
谷歌報錯 Warning: setState(...): Can only update a mounted or mounting component. This usually means you called setState() on an unmounted component. This is a no-op. Please check the code for the App component.
原因
原因是未及時清除掉定時器或者變量,造成了報錯會造成內存溢出|使用this定義變量,然后用componentWillUnmount()中清除定時器,方法見官方定時器demo,如下:
解決方案
class Timer extends React.Component {
constructor(props) {
super(props);
this.state = {secondsElapsed: 0};
}
tick() {
this.setState((prevState) => ({
secondsElapsed: prevState.secondsElapsed + 1
}));
}
componentDidMount() {
this.interval = setInterval(() => this.tick(), 1000);
}
componentWillUnmount() {
clearInterval(this.interval);
}
render() {
return (
<div>Seconds Elapsed: {this.state.secondsElapsed}</div>
);
}
}
ReactDOM.render(<Timer />, mountNode);
問題
谷歌報錯 ReactDOMComponentTree.js:113 Uncaught TypeError: Cannot read property '__reactInternalInstance$xvrt44g6a8' of null
at Object.getClosestInstanceFromNode.
和
Uncaught RangeError: Maximum call stack size exceeded
原因
未知,可能是圖片重復使用或者堆棧造成內存溢出和報錯
解決方案
將
render((
<Provider store={store}>
{routes}
</Provider>
), document.getElementById('root'));
改為
render((
<div>
<Provider store={store}>
{routes}
</Provider>
</div>
), document.getElementById('root'));
NodeJS后端及服務器端遇到的問題
問題
如何在后端運行時,忽略css文件,防止nodejs后端服務器報錯|node服務器不能正確解析css文件,所以會出現報錯|使用**asset-require-hook**插件排除css,也可以排除sass文件,防止nodejs讀取css報錯.
原因
前后端生成的css-modules不同,造成了部署到服務器時,會造成讀不到樣式,然后頁面閃現問題|原因是由于使用的組件**css-modules-require-hook**也是根據css-modules的機制,以file-path路徑進行生成的hash,所以由于css-modules-require-hook和webpack的目錄不同,所以造成了生成的hash不一樣只的問題|只需要在css-modules-require-hook組件中使用rootDir,將兩個目錄一致即可
解決方案
后端React 使用renderToString渲染 圖片路徑變為hash碼名稱|原因是由于Nodejs加載文件時,會自動轉為hash名稱|使用插件asset-require-hook鉤子來返回正確的圖片名稱
require('asset-require-hook')({
extensions: [
'jpg', 'png', 'gif', 'webp'
],
name: '[name].[ext]',
limit: 2000
});
后端權限驗證類
問題
使用passport時,一直無法寫入cookie,并無法驗證通過
原因
原因是由于沒有在執行代碼的時候,寫入await讓驗證操作執行完在進行后續操作,造成了問題
解決方案
只需要增加await,等待異步執行完成后傳接成功內容給http body,代碼如下:
await passport.authenticate('local', function(err, user, info, status) {.....}
協議
MIT
項目主頁:http://www.baiduhome.net/lib/view/home/1493706382369