React 與 Vue 之間的對比,是前端的一大熱門話題。
vue 簡易上手的腳手架,以及官方提供必備的基礎元件,比如 vuex,vue-router,對新手真的比較友好;react 則把這些都交給社群去做,雖然這壯大了 react 的生態鏈,但新手要弄出一套趁手的方案挺麻煩的,不過好在現在有很多類似 dva的方案了。
vue 比較討喜的一點,就是它的資料雙向流動在表單開發時特別方便,而 react 在這方面可就麻煩多了。
但是 vue 複雜的 api ,簡直讓人頭大,光是文件說明都幾十頁了。太多的語法,太多的魔法符號,對進化速度越來越快的前端屆來說,就是入手這個框架的最大阻礙。
而相反 react 的 api 數量簡直可以忽略不計了,頂多花幾小時就能看完官方文件。你只要理解 JavaScript,就能理解 react 的很多行為。react 的很多用法,它的 api 都是符合直覺的,你對它用法的猜測基本都是八九不離十的,這真是大大降低了心智負擔。
除此之外,react 的 jsx 語法表達能力更強,還有 hoc 和 hooks 使程式碼也更容易組織和複用。
雖然我更喜歡 React ,但工作上的需求,還不是要你用什麼你就得用什麼?,所以這個 demo 就當是探索 Vue 的前奏。
之前我還是有用過 vue 的,記得還是 1.0 版本,當時的潮流就是類似 angular 1.x 的 mvvm 方案,資料雙向流動。那時的 vue 遠沒有現在的熱度,元件也少,沒有 vue-router,沒有 vuex,元件之前的通訊簡直太痛苦了。現在 vue 2.x 比起之前,已經發生了天翻地覆的變化,vue 也在不斷向 react 靠攏,而我也只能從頭開始學起。
閒話說得有點多,還是趕緊進入主題吧
專案配置
選擇 webpack 4 打包和管理,template 引擎使用 pug ,css 預編譯是 scss。
webpack.common.js 的配置
// webpack.common.js
module.exports = {
entry: './src/main.js',
output: {
path: resolve(__dirname, 'dist'),
filename: '[name]-[hash].js'//輸出檔案新增hash
},
optimization: { // 代替commonchunk, 程式碼分割
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all'
}
}
}
},
module: {
rules: [
{
test:/\.vue$/,
exclude: /node_modules/,
use:['vue-loader']
},
{
test: /\.js?$/,
exclude: /node_modules/,
use: ['babel-loader']//'eslint-loader'
},
{
test: /\.pug$/,
use: ['pug-plain-loader']
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
},
{
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader']
},
{
test: /\.(png|jpg|jpeg|gif|eot|ttf|woff|woff2|svg|svgz)(\?.+)?$/,
use: [{
loader: 'url-loader',
options: {
limit: 1000
}
}]
}
]
},
plugins: [
new VueLoaderPlugin(),
new CleanWebpackPlugin([resolve(__dirname, 'dist')]),//生成新檔案時,清空生出目錄
new HtmlWebpackPlugin({
template: './public/index.html',//模版路徑
filename: 'index.html',//生成後的檔名,預設index.html
favicon: './public/favicon.ico',
minify: {
removeAttributeQuotes:true,
removeComments: true,
collapseWhitespace: true,
removeScriptTypeAttributes:true,
removeStyleLinkTypeAttributes:true
}
}),
new HotModuleReplacementPlugin()//HMR
]
};
webpack.dev.js 的配置
就是開發伺服器 devServer的配置,監控程式碼變更。
// webpack.dev.js
module.exports = merge(common, {
mode: 'development',
devtool: 'inline-source-map',
devServer: {
contentBase: './dist',
index:'index.html',
port: 3002,
compress: true,
historyApiFallback: true,
hot: true
}
});
babel.config.js 的配置
module.exports = {
presets: [
[
'@vue/app', {
"useBuiltIns": "entry"
}
]
]
}
目錄結構
public #公共目錄
server #後端目錄
src #前端目錄
├── assets #靜態檔案目錄
├── common #工具目錄
├── components #元件目錄
├── store # vuex store目錄
├── App.vue # 根元件
├── main.js # 入口檔案
└── router.js #路由
入口和路由
路由檔案
下面使用了巢狀路由,使用的是基於 history 的路由,也可以選擇基於 hashchange的路由。
import Vue from 'vue'
import Router from 'vue-router'
//...
Vue.use(Router)
//路由
const routes = [{
path: '/',
name: 'home',
component: Index
},{
path: '/sign',
name: 'sign',
component: Sign,
children: [ //巢狀路由
{
path: "log",
name: "login",
component: Login
},
{
path: "reg",
name: "register",
component: Register
},
{ path: '*', redirect: 'log' }
]
}, { path: '*', redirect: '/' }]
export default new Router({
mode: "history",
routes
})
入口檔案
把router,store 和根元件組合起來
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import '../public/base.min.css'
import '../public/fontello.css'
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App),
}).$mount('#app')
模組的編寫
模版,邏輯程式碼,樣式合成到一個頁面也是我欣賞 vue 的一個方面,因為這樣你就不需要在多個檔案之間反覆的切換。
模版template
pug 就是之前的 jade,它的簡潔在複雜的頁面下會讓 template 清晰不少,最起碼會讓你少敲程式碼,這裡以index 頁面的部分程式碼為例。
<template lang="pug">
div.content
div.bar
header(v-drag)
div.avatar(v-on:click="profile(selfInfo)")
img(:src="selfInfo.avatar? selfInfo.avatar: aPic.src")
div.name {{ selfInfo.nick }}
p {{ selfInfo.signature}}
i.icon-logout(v-on:click="logout")
div.body
div.main-panel(v-if="!isSearch")
nav
div(v-on:click="showTab(0)" :class="{active:tabIndex==0}") 好友
div(v-on:click="showTab(1)" :class="{active:tabIndex==1}") 分組
div(v-on:click="showTab(2)" :class="{active:tabIndex==2}") 訊息
span(v-if="dealCount") {{dealCount}}
ul.friends(v-if="tabIndex == 0")
li(v-for="item in friends" :key="item.id")
div.avatar(v-on:click="profile(item)")
img(:src="item.avatar? item.avatar: aPic.src")
p(v-on:click="chatWin(item)") {{item.nick}}
span(v-if="item.reads && item.reads > 0") ({{item.reads}})
//動態建立元件
component(:is="item.component" v-for="(item,i) in wins" :key="item.id"
:info="item.info"
:sty="item.sty"
:msgs="item.msgs"
v-on:close="closeWin(i)"
v-on:setZ="setZ(i)")
</template>
動態建立元件
上面用到了 vue 的 動態建立元件 的概念,什麼意思呢?這個元件在當前頁面中是不存在的,需要我們觸發之後,才開始建立。比如,當你點選某個按鈕,才開始載入建立元件,然後填充到頁面中來。下面就是動態元件相關功能的編寫。
data() {
return {
wins: [] //元件列表
}
},
methods: {
addWin(info, com) { // 新增元件的方法
this.wins.push({
msgs: info.msgs || [],
info,
sty: {
left: l * 30 + 270,
top: l * 30 + 30,
z: 0
},
component: com
});
}
}
//填充元件
component(:is="item.component" v-for="(item,i) in wins" :key="item.id"
:info="item.info"
:sty="item.sty"
:msgs="item.msgs"
v-on:close="closeWin(i)"
v-on:setZ="setZ(i)")
javascript部分
這裡就是業務邏輯的部分了,以部分程式碼為例, 具體的部分參考官方的文件
<script>
import { mapState, mapGetters } from "vuex";
import ChatMsg from "./ChatMsg.vue";
import Profile from "./Profile.vue";
import { get, post } from "../common/request";
export default {
name: "index",
data() {
return {
tabIndex: 0,
wins: [],
aPic: {
src: require("../assets/avatar.jpg")
}
};
},
async created() {
//...
},
computed: {
...mapState(["selfInfo"]),
...mapGetters([
"isLogin",
"friends",
"msgs"
])
},
watch: {
isLogin: {
//監聽登入狀態
handler: function(val, old) {
//...
}
// ,immediate: true //進入元件立即執行一次
}
},
methods: {
addWin(info, com) {},
sendMsg(user,data){}
//...
}
}
</script>
style部分
使用了 vue 預設的 scoped ,當然最完善的方案是 css-module,配置要複雜一些,當然這要看你專案需求。預編譯器使用的是 scss,個人認為比較強大和方便。
<style lang="scss" scoped>
$blue: hsl(200, 100%, 45%);
@mixin nowrap {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.content {
height: 100%;
width: 1000px;
margin: 0 auto;
position: relative;
}
.main-panel {
width: 100%;
}
.search-panel {
width: 100%;
min-height: 313px;
max-height: 513px;
li {
line-height: 2;
}
}
.bar {
position: absolute;
top: 30px;
width: 250px;
background-color: #fff;
user-select: none;
box-shadow: 0 6px 20px 0 hsla(0, 0%, 0%, 0.19),
0 8px 17px 0 hsla(0, 0%, 0%, 0.2);
header {
display: flex;
align-items: flex-start;
align-items: center;
background-color: $blue;
color: #fff;
.avatar {
width: 30px;
height: 30px;
margin: 10px;
border: 1px solid $blue;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
&:hover {
border-color: #fff;
}
img {
width: 100%;
height: 100%;
}
}
}
}
<style>
vuex的使用
vuex 相比 react 中的 redux,使用起來也更加簡單和方便,儘管相比 redux 可能沒有那麼 "純",但好用就行。 vuex 直接把非同步的 action 封裝進裡面,使用module將不同元件的狀態區分開來。可以說 vuex 的 store 集中了 專案大部分與 狀態相關的業務邏輯,這也是 vue 專案的一大關鍵點。
store
vuex 的 store 和 redux 的 store 一樣。
import Vue from 'vue'
import Vuex from 'vuex'
import { state, mutations } from './mutations'
import * as getters from './getters'
import * as actions from './actions'
import friend from './modules/friend'
import msg from './modules/msg'
Vue.use(Vuex)
export default new Vuex.Store({
actions,
getters,
state,
mutations,
modules: {
friend,
msg
}
})
全域性 state 和 mutations
vuex 中的 state 對應 redux 的 state,mutations 則類似 redux 中的 action,其中mutations是同步的。
export const state = {
loginInfo: { token },
selfInfo: selfInfo,
dialog: { txt: 'content', cancal: false, callback: () => { }, show: false }
}
export const mutations = {
showDialog(state, payload) {
state.modal.visible = true;
state.dialog = Object.assign({}, state.dialog, payload);
state.dialog.show = true;
},
closeDialog(state) {
state.modal.visible = false;
state.dialog.show = false;
},
setLoginInfo(state) {
state.loginInfo = { token: localStorage.getItem("token") };
},
setSelfInfo(state, payload) {
state.selfInfo = payload;
localStorage.setItem("selfInfo", JSON.stringify(payload));
},
logout() {
state.loginInfo = {};
state.selfInfo = {};
localStorage.clear();
}
}
全域性 action 和 getters
vuex 的 aciton 就是將非同步的動作封裝起來。而redux 得通過 redux-saga 之類的中介軟體才能實現類似的效果。
import { get, post } from "../common/request";
export const getInfo = ({ commit }) => {
return get("/getinfo").then(res => {
if (res.code == 0) {
commit("setSelfInfo", res.data.user);
commit("setFriends", res.data.friends);
commit("setGroup", res.data.groups);
commit("setMsgs", res.data.msgs);
} else if (res.code == 1) {
commit("logout");
} else {
commit('showDialog',{txt:res.message})
}
}).catch(err=>{
commit('showDialog',{txt:err.message})
});
}
export const updateSelf=({commit},form)=>{
post("/updateinfo", form).then(res => {
if (res.code == 0) {
commit("updateSelfInfo", form);
} else if (res.code == 1) {
commit("logout");
} else {
commit('showDialog',{txt:res.message})
}
}).catch(err=>{
commit('showDialog',{txt:err.message})
});
}
getters可以看成是對state 中某些欄位的封裝
export const visible = state => state.modal.visible
export const isLogin = state => !!state.loginInfo.token
modules
隨著專案規模的擴充套件,拆分和模組化都是一個必然。針對某個子模組而設定的store,它的結構和根store一樣,module 的 store 最終會合併到根 store裡面。msg為例的編寫方式如下:
import { get, post } from "../../common/request";
export default {
state: {
msgs: []
},
getters: {
msgs: state => state.msgs,
dealCount: state => state.msgs.filter(i => i.status == 0).length
},
actions: {
accept({ commit }, form) {
return post("/accept", { id: form.id, friend_id: form.from_id }).then(res => {
if (res.code == 0) {
commit("setMsgState", { id: form.id, status: 1 });
commit("addFriend", Object.assign({}, form, { id: form.from_id }));
} else {
commit('showDialog',{txt:res.message})
}
}).catch(err=>{
commit('showDialog',{txt:err.message})
});
},
reject({ commit }, form) {
post("/reject", { id: form.id }).then(res => {
if (res.code == 0) {
form.status = 2;
commit("setMsgState", form);
} else {
commit('showDialog',{txt:res.message})
}
}).catch(err=>{
commit('showDialog',{txt:err.message})
});
}
},
mutations: {
setMsgs(state, payload) {
state.msgs = payload;
},
setMsgState(state, payload) {
state.msgs.forEach(i => {
if (i.id == payload.id) {
i.status = payload.status;
}
})
},
addMsg(state, payload) {
state.msgs.unshift(payload);
}
}
}
socket.io的接入
接著將websocket使用起來,讓我們實現 好友聊天和分組聊天的功能,socket.io 的介紹可以看我之前的文章 關於socket.io的使用。
客戶端
首先連線服務端的 socket,然後將自身的使用者資訊註冊到 socket.io 服務,這樣服務端才知道你是誰,也才能與其他人實行通訊。
async created() {// vue 元件建立時建立socket連線
const token = localStorage.getItem("token") || "";
if (!token) {
return this.$router.push("/sign/log");
}
await this.$store.dispatch("getInfo");
this.socket = io("http://localhost:3001?token=" + token);
//註冊使用者資訊後才開始與服務端通訊
this.socket.emit("sign", { user: this.selfInfo, rooms }, res => {
// console.log(res);
this.$store.commit("friendStatus", res.data);
this.socket.on("userin", (map, user) => {
this.$store.commit("friendStatus", map);
showTip(user, "上線了");
});
this.socket.on("userout", (map, user) => {
this.$store.commit("friendStatus", map);
showTip(user, "下線了");
});
this.socket.on("auth", data => {
this.$store.commit('showDialog',{txt:data.message})
this.$store.commit("logout");
});
//接收申請好友和組群
this.socket.on("apply", data => {
this.$store.commit("addMsg", data);
});
//接收聊天資訊
this.socket.on("reply", (user, data) => {
this.sendMsg(user, data);
});
//接收群組聊天資訊
this.socket.on("groupReply", (info, data) => {
this.sendGroupMsg(info, data);
});
});
},
beforeDestroy() { //元件銷燬之前,將socket 關閉
this.socket.close();
},
服務端
socket.io 對應的服務端部分,邏輯主要包括使用者註冊,兩人聊天,群聊天,當然對應的資訊需要儲存到資料庫。 這裡的技巧就是使用變數記錄當前所有登入使用者的資訊。
const auth = require('./auth.js')
const { insertMsg, insertToUser } = require('../daos/message');
const log = require('../common/logger')
let MAP = {};//使用者id和socket id
let LIST = []; //使用者資訊
let ROOMS = []; //房間
const currTime = () => {
const d = new Date(), date = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
return ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2) + ':' + ('0' + d.getSeconds()).slice(-2);
};
module.exports = io => {
// middleware
io.use(auth);
//namespace (/)
io.on('connection', socket => {
socket.emit('open', {
code: 0,
handshake: socket.handshake,
namespace: '/',
message: 'welcome to main channel, please sign'
});
//使用者註冊
socket.on('sign', ({ user, rooms }, fn) => {
if (!user.id) {
return fn({ code: 2, message: 'id not exist' });
}
MAP[user.id] = socket.id;
user.socketId = socket.id;
LIST.push(user);
socket.join(rooms);//加入自己所在的組
socket.emit('userin', MAP, user);
socket.broadcast.emit('userin', MAP, user);
fn({
code: 0,
message: 'sign success',
data: MAP
});
});
//兩人聊天
socket.on('send', async (uid, msg) => {
const sid = MAP[uid];//接收使用者socket.id
const cid = findUid(socket.id);//傳送使用者id
if (sid) { // 好友線上則傳送
socket.to(sid).emit('reply', { id: cid, self: false }, { date: currTime(), msg });
}
// 給自己也發一份
socket.emit('reply', { id: uid, self: true }, { date: currTime(), msg });
// 儲存資料庫
try {
const ret = await insertMsg({ send_id: cid, receive_id: uid, content: msg });
insertToUser({ user_id: uid, send_id: cid, message_id: ret.insertId, is_read: sid ? 1 : 0 });
} catch (err) {
log.error(err);
}
});
//群組聊天
socket.on('groupSend', async ({gid,user}, msg) => {
//...
});
socket.on('acceptFriend', (uid) => {
//...
});
socket.on('sendApply', (uid, data) => {
//...
});
socket.on('disconnect', () => {
//...
});
});
};
客戶端的啟動
首先得編寫client.js,將前端服務啟動起來,依然還是使用我們高效的koa框架。我這裡圖省事,和之前的服務端所在同一個根目錄下,真正專案會將服務端部分和客戶端部分 分離到不同目錄或不同的伺服器的。
const koa = require('koa')
const app = new koa()
const static = require('koa-static')
const compress = require('koa-compress')
const router = require('koa-router')()
const { clientPort } = require('./server/config/app')
const tpl = require('./server/middleware/tpl')
const path = require('path')
// gzip
app.use(compress({
filter: function (content_type) {
return /text|javascript/i.test(content_type)
},
threshold: 2048,
flush: require('zlib').Z_SYNC_FLUSH
}));
// set static directiory
app.use(static(path.join(__dirname, 'dist'), { index: false }));
// simple template engine
app.use(tpl({
path: path.join(__dirname, 'dist')
}));
// add routers
router
.get('/', ctx => {
ctx.render('index.html');
})
.get('/sign/*', ctx => {
ctx.redirect('/');
})
app.use(router.routes())
.use(router.allowedMethods());
// deal 404
app.use(async (ctx, next) => {
ctx.status = 404;
ctx.body = { code: 404, message: '404! not found !' };
});
// koa already had event to deal with the error, just rigister it
app.on('error', (err, ctx) => {
ctx.status = 500;
ctx.statusText = 'Internal Server Error';
if (ctx.app.env === 'development') { //throw the error to frontEnd when in the develop mode
ctx.res.end(err.stack); //finish the response
} else {
ctx.body = { code: -1, message: 'Server Error' };
}
});
if (!module.parent) {
app.listen(clientPort);
console.log('app server running at: http://localhost:%d', clientPort);
}
啟動服務端和客戶端,我們整個demo就能執行,主要實現如下功能點:
- 主頁面的所有的視窗都可以拖動,關閉
- 可以編輯使用者資訊,群組資訊,每個使用者可以新建3個群組
- 可以好友聊天,群組聊天
- 搜尋使用者和群組
- 好友申請和群組申請
- 線上時,可以獲得好友上線下線提醒,實時答覆使用者申請
- 離線時,仍然可以給使用者和群組留言,下次登入獲得提醒
後續
接下來可以優化和增強的地方,我想到以下幾點:
- 使用 nuxt 將 vue 進行服務端渲染 ,進一步提高效能
- node 部分,使用 pm2 進行部署。
原始碼: vue_qq