一、前面bb兩句
因為自慚(自殘)webpack配置還不夠熟悉,想折騰着做一個小實例熟悉。想着七夕快到了,做一個聊天室自己和自己聊天吧哈哈。好了,可以停止bb了,說一下干貨。
二、 這個項目能學到啥?
為了減少秒關文章的沖動。我得把好話放在前頭。做了這個項目,我學會了....(對於我).
- Webpack的配置以及各個參數概念都有一定的熟悉。
- React+Webpack+Express的配合使用
- 熟悉React的JSX語法、生命周期等的熟悉
- Socket.io(入門)
- localStorage(入門)
- less(入門)
以上的都或多或少地涉及了(大神請別見笑)。不知道有沒有和我一樣的小伙伴以前看到socket、localStroage之類的都只懂個概念,真正使用還真沒個數。沒有嗎?好吧。其實這幾個東西寫起來真的不難,和他高大上的概念並不成比例。
例如socket.io只需要20行代碼就能完成基本功能
localStroage也需要創建一個對象,一個方法即可完成。
所以無需懼怕!繼續看下去
三、 項目涉及的技術及地位
-
Webpack 地位:★ ★ ★ ★ ★
-
原因: 因為項目最初構建目的是一步步熟悉Webpack的配置,以及和React、node的搭配,所以不給滿星Webpack怕是會鬧別扭。
-
內容: 基礎知識的配置(入口文件等等),loader的配置(react加載器等),配置熱更新,打包后自動生成html文件...
-
擴展: 如果想要先熟悉了解webpack的一些基礎知識,可以參考《入門及配置Webpack》
-
-
Express(node) 地位:★ ★ ★ ★ ☆
- 原因: 雖然同樣是不可或缺的地位,沒開啟服務怎么訪問呀!但是之所以低Webpack一等(僅僅指在這個項目),是因為對node的配置不多,大部分都是通過express自動生成的。在此項目,更改的就只有app.js渲染的文件類型(默認是jade,更改為html)還有指向文件。
- 內容: 渲染文件類型、更改指向目錄、更改端口...
- 擴展:確保安裝了express,然后通過$ express myappName初始化構建項目即可
-
React 地位:★ ★ ★ ★ ☆
- 原因: 你說不用react也可以構建聊天室?當然可以,但是我們項目畢竟是React+Webpack,不用react的話...挺尷尬的?所以項目也要求你要懂一些react的語法啦。掌握一些基礎知識即可:自定義組件、父子傳值之類...
- 內容: 頁面內容的呈現、邏輯的處理(其實就是普通html、js)
- 擴展: 基礎.沒...沒啥好擴展的啦(項目一開始用到了react-redux,但是后面發現沒什么必要就去掉了)
-
socket.io、localStroage、Less 地位:★ ★ ★ ☆ ☆
- 原因:把這三類歸在一起,一來是因為我對三類都不太熟悉(所以跟我一樣的不用怕!不會很復雜)
- 內容: socket.io負責接收某位客戶端傳來的信息,並廣播到所有客戶端上。
localStroage的加入有點勉強,我只是順便想熟悉一下它,並嘗試保存聊天記錄。具體作用是通過localStroage獲取用戶信息,如果沒有則添加。但是我在最開始會清除掉localStroage,所以每次刷新頁面的時候都需要重新填寫,所以項目localStroage存在作用不大,只是代替了模擬數據。
項目使用的Less也比較基礎,只是簡化了層級關系的寫法(這一點確實比css方便很多) - 擴展: socket.io用法可看:《socket.io中文文檔》
localStroage用法可看:《localStroage入門》 - Socket.io將Websocket和輪詢(polling)機制以及其他通信方式封裝成通用接口,解決了瀏覽器的兼容性
四、摩拳擦掌:准備項目前期
- 我們先來看一下項目部分截圖:


想看gif動圖的可以直接跳下去哦
是不是很想親自做一個出來?別急,我們這就開始。打開VSCode,打開音樂!! - 因為項目是通過edxpress初始化的,所以需要安裝express,可通過express --version檢查自己的版本確保安裝(我的版本是4.16.0)。如果未安裝,可執行:$ npm install express -g
express SoyChat //創建express項目,名字個人喜歡
cd SoyChat //進入目錄
npm install //安裝依賴
node bin/www //啟動項目
訪問localhost:3000 看到Welcome to Express的話恭喜你!闖過第一關!
注意:啟動命令也可以用npm run start 啟動,因為package.json的script里面已經默認設置了npm run start指代 node ./bin/www命令。兩個使用其中一個都可以啟動項目! 如果遇到端口占用情況,進入bin/www文件修改端口即可。
3.就知道這點難不倒你。開始動手寫項目前我把最終目錄寫一下,方便后面參考使用。(可跳過)
SoyChat /
bin/
www //默認生成文件,服務啟動文件
client/ //客戶端,編寫代碼的地方
components //公共組件
dist //打包后存放位置
modules //主要的邏輯組件
r_routes //react組件路由
views //模板文件、React渲染文件
index.html
node_modules/
public/ //存放圖片等靜態資源
routes/ //默認生成文件,express設置路由文件
index.js
app.js //默認生成文件,服務啟動配置
package.json
webpack.config.js //webpack配置文件
4.完整項目的github地址:小語1.0
拷貝到本地之后
npm install //安裝依賴
npm run build //打包
npm run start //啟動服務
瀏覽器訪問localhost:8000,測試聊天可開多一個窗口
五、開戰!編寫項目
1.更改服務啟動相關配置(app.js)
刪除routes/文件下的user.js 去掉app.js引入的userRouter、app.use('/users',userRouter)
更改視圖渲染文件的類型:jade => html
var ejs = require('ejs'); //需要安裝ejs模塊:npm install ejs --save
app.engine('html', ejs.renderFile);
app.set('views', path.join(__dirname, './client/dist')); //html文件加載路徑
app.set('view engine', 'html');
app.use(express.static(path.join(__dirname, './client/dist'))); //css.js...之類文件加載路徑
可能會疑惑./client/dist是個什么東西?
其實這個文件是我們打包后存放的位置,我們不直接訪問React渲染的html頁面,而是指向webpack打包后生成的html;
例如,這個項目最終打包好后的dist文件如下:

好了,node服務我們配置到這就完事了.啥?真的就這么簡單。
2.高能預警:Webpack的配置(敲桌子!)
安裝Webpack依賴:npm install webpack --save-dev
這里的--save-dev是把依賴加載到package.json的devDependencies中,--save是安裝到dependencies中。前者是開發所需要用到的,后者是生產環境需要用到的。這里不做具體介紹,可看《入門及配置Webpack》
呼~終於安裝好了。接着新建一個webpack.config.js文件吧。
//webpack.config.js
var webpack = require('webpack');
var path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: __dirname + '/client/r_routes/index', //入口文件
output: {
path:path.join(__dirname + '/client/dist'), //打包后存放位置
filename:'bundle.js', //打包后的文件名
},
module :{
loaders : [{
test :/(\.jsx|\.js)$/,
exclude : /node_modules/,
loader :'babel-loader',
options:{
presets:[
"env", "react"
]
}
},
{
test : /\.css$/,
loader:'style-loader!css-loader'
},
{
test: /\.less/,
loader: 'style-loader!css-loader!less-loader'
},
{
test: /\.(png|jpg)$/,
loader: 'url-loader?limit=8192'// limit 字段代表圖片打包限制
}
]
},
plugins: [
//根據index.html作為模板,打包的時候自動生成html並引入打包的js文件
new HtmlWebpackPlugin({
template: __dirname + "/client/views/index.html"
}),
//引入全局webpack
new webpack.ProvidePlugin({
$:"jquery",
jQuery:"jquery"
})
],
}
接下來介紹里面的幾個參數:
- entry:打包的入口文件.這邊指向react的根路由文件r-routes/index。打包的時候會從該文件入口,一層層獲取所有組件
//r-routes/index
import React from 'react';
import ReactDOM from 'react-dom';
import ReactApp from '../modules/r_app'//根組件
ReactDOM.render(<ReactApp />,document.getElementById('app'));
可能會疑惑,如何知道把ReactApp組件render(渲染)到哪個html的id=app上呢?
原來這個和webpack的plugins(插件)的new HtmlWebpackPlugin有關。這個對象會讀取一個目錄下的html文件為模版,然后經過處理后再去output指定的目錄輸入一個新的html文件。因為在這里指定了/client/views/index.html文件為模板,所以react的所有都會渲染到這個html文件中。
-
output:配置打包輸出位置以及輸出文件名字。(html的生成是通過new HtmlWebpackPlugin方法)
-
module:里面是各種loader加載器;webpack理論上只能加載js文件,但是通過各種loader它可以加載圖片、css等等文件。
項目用到的loader:style-loader、css-loader、file-loader...詳見package.json
要使webpack打包支持react和ES6語法還需要安裝babel等依賴
npm install --save-dev react react-dom babelify babel-preset-react
npm install --save babel-preset-es2015 //支持ES6語法
//loader配置參考上面的即可
-
plugins:各種插件配置
例如上面全局jquery的配置(記得安裝jquery依賴包npm install jquery --save) -
注意我這里沒有配置熱更新,因為熱更新有自己的服務,但我想使用node啟動服務,不用webpack-dev-server的服務,所以就沒配置(網上應該有解決方法,給node服務添加熱更新,但是我沒找到,所以項目只有自動打包,但還是需要手動刷新瀏覽器)
npm install webpack-dev-server --save-dev //熱更新安裝
至此,webpack.config.js配置完成。接下來我們看看package.json
//package.json
{
"name": "app",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./bin/www",
"build": "webpack --progress --watch"
},
"dependencies": {
"cookie-parser": "~1.4.3",
"debug": "~2.6.9",
"ejs": "^2.6.1",
"express": "~4.16.0",
"http-errors": "~1.6.2",
"jade": "~1.11.0",
"jquery": "^3.3.1",
"less": "^3.8.1",
"morgan": "~1.9.0"
},
"devDependencies": {
"babel-core": "^6.26.3",
"babel-loader": "^7.1.5",
"babel-preset-env": "^1.7.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"css-loader": "^1.0.0",
"file-loader": "^1.1.11",
"html-webpack-plugin": "^3.2.0",
"http-proxy-middleware": "^0.18.0",
"less-loader": "^4.1.0",
"react": "^16.4.2",
"react-dom": "^16.4.2",
"react-router-dom": "^4.3.1",
"socket.io": "^2.1.1",
"socket.io-client": "^2.1.1",
"style-loader": "^0.22.1",
"url-loader": "^1.0.1",
"webpack": "^3.0.0",
"webpack-cli": "^3.1.0",
"webpack-dev-middleware": "^3.1.3",
"webpack-dev-server": "^2.9.7"
}
}
安裝的依賴包我就不具體介紹,重點介紹scripts的參數
...
"scripts": {
"start": "node ./bin/www",
"build": "webpack --progress --watch"
},
...
這里是可根據情況配置一些指代命令。
本來項目啟動需要node ./bin/www,但是通過配置,我終端輸入npm run start(npm run + 指令)也能達到一樣的效果。
同理,我利用npm run build 代替了webpack的打包命令,並附帶了一些參數命令。
--progress //顯示進度條
--watch //監聽變動並自動打包
-p //壓縮腳本
大吉大利!枯燥的項目配置到此結束!
3. 熟悉的前端味道:編寫React組件
//r-routes/index.js
import ReactApp from '../modules/r_app' //根組件
我們可以看到r-routes/index.js引用了一個根組件r_app,r_app再由組件AppHead、AppContent、AppFoot 構成。

1. localStroage的使用
值得注意的是剛進入頁面的時候,輸入信息框會根據localStroage是否含有用戶信息來決定是否出現

//r_app.js
//引入組件
import AppHead from './head/index'
import AppContent from './content/index'
import AppFoot from './foot/index'
import UserInfoModal from '../component/userInfoModal/index'
import './r_app.less'
...
const storage = window.localStorage;
storage.removeItem('userInfo'); //進入頁面時清除localStroage
if(!storage){
// console.log("瀏覽器不支持localstorage");
return false;
}else{
// console.log("瀏覽器支持localstorage");
//判斷是否存在localStroage
if(storage['userInfo']){
//已經存在localStroage.隱藏輸入信息框
this.setState({
userInfo:JSON.parse(storage['userInfo']), //把StringObject轉換成Object
userInfoState:true,
})
}
}
}
...
render (){
// console.log(this.state.userInfo)
return (
<div className="appWried" >
{
this.state.userInfoState ? '' : <UserInfoModal onSubmitData={this.onGetData} />
}
{
<div style={{height:'100%',width:'100%'}} className={this.state.userInfoState ? '' : 'unClick'} >
<AppHead />
<AppContent userInfo={this.state.userInfo}/>
<AppFoot userInfo={this.state.userInfo} />
</div>
}
</div>
);
}
可以發現,r_app.js只是做了localStroage的讀取和判斷,但是並沒有寫入任何,localStroage字段。並且永遠不會進入if(storage['userInfo'])語句,因為每次在最前面都會把信息remove。所以信息輸入框每次刷新頁面都依然會彈出來.
耍我呢?localStroage出來秀逗的?
= =localStroage在這里確實有點大材小用,因為一開始想持續性保存用戶的信息以及聊天記錄,但是發現這樣測試難以進行。我就一部電腦,讀取的localStroage['userInfo']不就一模一樣么。
說回正題,那添加localStroage的操作在哪執行?
答案就在<UserInfoModal onSubmitData={this.onGetData}/>子組件里,當用戶在<UserInfoModal />提交信息后,存儲到localStroage並且把數據傳回r_app,然后r_app再執行對應操作
//UserInfoModal.js
...
<button className="submitBtn" onClick={this.submitFn.bind(this)}>提交</button>
submitFn(){
let userName = $('#userName').val();
if(!userName){
alert('名字還未輸入哦')
return;
}
let headImg = this.state.choseImg;
let userId = "indexCode" + Math.round(Math.random() * 100000); //隨機創建id,用來判斷是自身信息還是別人信息
//數據傳回父組件r_app.js
this.props.onSubmitData({
userName,
headImg,
userId,
})
}
...
//r_app.js
...
//子級返回數據
onGetData (e){
// console.log(e);獲得子組件傳遞的數據,包括userName和headImg
let userInfo = {};
userInfo.userName = e.userName;
userInfo.headImg = e.headImg;
userInfo.userId = e.userId;
this.setState({
userInfo,
userInfoState:true,//隱藏輸入信息框
},function(){
const storage = window.localStorage;
storage['userInfo'] = JSON.stringify(this.state.userInfo);//localStorage只能存儲String類型,需將對象轉換成string
// console.log(storage['userInfo'])
})
}
...
注意:localStroage只能存儲String類型的數據,如果需要存儲對象,需要通過JSON.Stringify()轉換。取數據的時候通過JSON.parse()即可
2. Socket.io的使用
實現效果:底部input發送的數據傳遞到content組件並展示,並且要求所有客戶端都能收到。
實現思路:利用socket.io實現實時通信,先把發送信息的客戶端的用戶個人信息以及發送內容中轉到服務器,服務器再分派給所有訂閱了這個socket事件的客戶端。接收到消息的<AppContent />把信息顯示到內容上。
- 安裝socket.io
npm install socket.io --save-dev //安裝服務器端的socket.io
npm install socket.io-client --save-dev //安裝客戶端的socket.io-client
- 服務器端使用socket.io
// bin/www
//新增socket.io模塊
var io = require('socket.io')(server);
io.on('connection', function(socket){
//接受客戶端傳送的sendMessage命令
socket.on('sendMessage', function(ioUserInfo,msg){
console.log(ioUserInfo); //用戶ioUserInfo
console.log(msg); //接收用戶的發送信息
//通過接受sendMessage這個action的數據再廣播給所有'訂閱的人'(即on了這個事件的)
socket.broadcast.emit('getMessage', ioUserInfo, msg);
//socket.emit()發送信息給全部人,只要訂閱了getMessage的人都會收到變量ioUserInfo和msg
//socket.broadcast是發送除自己外的人
});
})
引入socket.io模塊,當處於connection的時候即可進行接收、發送信息。上面服務器接收(on)到某個用戶傳來的信息之后再廣播(emit)給大家
on和emit可以這么理解,接收信息是on事件,發送信息是emit事件
//發送標志為message信息,信息內容為:test
socket.emit('message','test')
//訂閱了標志為message的信息的客戶端將會接收到這條test信息
socket.on('message',function(data){
console.log(data);//test
})
- 客戶端端使用socket.io-client
const socket = require('socket.io-client')('http://localhost:8000');
socket.on()....
socket.emit()...
- 客戶端發送io.socket信息
//footComponent.js
...
componentDidMount(){
document.addEventListener("keydown",this.handleEnterKey);//綁定一個鍵盤按下的方法
}
//點擊按鈕發送信息
clickBtn(){
const { message } = this.state; //獲取input輸入的內容
const { userInfo } = this.props;
// console.log('發送' + this.state.message)
//觸發發送內容的函數
this.sendMessage(userInfo,message);
}
//回車后發送信息
handleEnterKey(e){
let that = this;
const { message } = this.state; //獲取input輸入的內容
const { userInfo } = this.props;
if(e.keyCode === 13){
//回車keyCode==13
//是否發送內容
this.sendMessage(userInfo,message);
}
}
//發送內容函數
sendMessage(ioUserInfo,message){
if(message){
this.sendSocketIO(ioUserInfo,message);//發送websocket的函數
this.setState({
message:'', //清空input內容
})
}
}
sendSocketIO(ioUserInfo,message){
socket.emit('sendMessage',ioUserInfo,message) //客戶端發送
}
let disabled = Object.keys(this.props.userInfo).length ? '' : 'disabled'; //未填用戶信息的時候禁止input輸入內容
return (
<div className="footDiv">
<input disabled={disabled} className="footIpt" placeholder="請輸入..." onChange={this.dataChange} value={this.state.message}/>
<button className={`footBtn ${this.state.hasCont}`} onClick={this.clickBtn}>發送</button>
</div>
)
- 客戶端接收信息
//content/index.js
socket.on('getMessage', function(ioUserInfo,msg){
console.log(ioUserInfo); //ioUserInfo為發送msg的用戶信息
console.log(msg) //用戶發送的內容
}
- 判斷是己方信息還是對方信息
通過一開始分配的userId來區分信息類型,如果是己方信息,應用向右浮動樣式(.contLiMy);如果是對方信息,應用向左浮動樣式(.contLiOther)
componentWillReceiveProps(nextProps){
const { userInfo } = nextProps; //獲取父級傳遞過來的userInfo,里面攜帶自身的userId
socket.on('getMessage', function(ioUserInfo,msg){
console.log(ioUserInfo);
// 如果socket傳回ioUserInfo.userId和自身相同,則判斷為自身發送的信息
let appendLi = ''
if(ioUserInfo.userId == userInfo.userId){
appendLi = `<li class="contLi contLiMy">
<div class="contLiMy">
<div class="headImg">
<img src=${userInfo.headImg} />
</div>
<div class="chatContent">
<div class="chatName">
<span>${userInfo.userName}</span>
</div>
<div class="chatBg">
<span>${msg}</span>
</div>
</div>
</div>
</li>`
}
else{
appendLi = `<li class="contLi contLiOther">
<div class="contLiOther">
<div class="headImg">
<img src=${ioUserInfo.headImg} />
</div>
<div class="chatContent">
<div class="chatName">
<span>${ioUserInfo.userName}</span>
</div>
<div class="chatBg">
<span>${msg}</span>
</div>
</div>
</div>
</li>
`
}
$('.contUl').append(appendLi);
});
}
return(
<div className="content">
<ul className="contUl">
<div className="contTop">歡迎你:{this.props.userInfo.userName}</div>
</ul>
</div>
)
這里有一個小技巧,如果內容超出高度出現滾動條的時候,需要保持顯示底部的內容.在填充內容后加上一行代碼
...
$('.contUl').append(appendLi);
$('.contUl').scrollTop($('.contUl')[0].scrollHeight);//保持顯示滾動條高度的位置(即底部)
...

至此項目的主要功能都已經完成啦。未介紹的less其實和css寫法差不多,這里less只是簡化了父層的寫法

一些點擊、hover功能都是用最基礎的js、jq實現的。
例如:聊天框的實現(利用偽類:after制作三角形)
//己方聊天框
.chatBg{
font-size:15px;
padding:3px 10px;
border-radius: 3px;
color: #EFEFEF;
background-color: #8c628d;
margin-right:8px;
position: relative;
}
//聊天框三角形的制作
.chatBg:before{
right:-12px;
border-color:transparent transparent transparent #8c628d; //四邊分別代表:上右下左
}
六、馬后話(總結)
1. 總結
項目總體的實現沒有難度,都是最基礎的東西。能幫助初學者(例如我)學到和鞏固知識點才是最重要的!有任何疑問或者任何錯誤,歡迎留言啦!謝謝小伙伴們的耐心閱讀~~
2. 最后給大家呈上一張最終效果的gif圖

