Vite+WebSocket+Express+quill跨端內容同步

 

最近經常碰到一種場景,手機複製粘貼一些文本等內容到電腦,或者電腦發到手機。

實現這個功能的方案很多,但是基本上要麼是手機連上電腦,要麼兩端各安裝一個什麼應用,侷限性還是很大。於是就有了一個想法,直接自己搞一個類似聊天室的網站不就好了,手機掃個碼就能打開,電腦上只要有瀏覽器就行。websocket把編輯的內容實時同步到服務器,多方便😁——emmm....啊這,這不就是谷歌文檔麼😅。。。那就當自己玩玩吧,技多不壓身嘛~而且自己搭的服務器,這些簡單的功能也不用搞什麼複雜的鑑權,用起來也方便~~

既然明確了目的就是玩,那就玩點新東西好了,首先嚐試下vite:

創建vite工程sync-site

npm init vite@latest sync-site --template vue

這裏我們並不需要vue,但是新的vite必須要指定一個框架,好在不會自動幫我們安裝依賴,我們可以進入sync-site目錄,手動去掉不必要的文件和代碼。

刪除vue相關的文件和代碼

①移除package.json裏vue相關的依賴

{
  "name": "sync-site",
  "private": true,
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
  },
  "devDependencies": {
    "vite": "^2.8.0"
  }
}

②刪除components目錄和其他.vue後綴的文件

③vite.config.js移除vue相關代碼

import { defineConfig } from 'vite'
export default defineConfig({
})

④main.js裏的代碼改爲

document.getElementById('app').innerHTML = 'Hello Vite!'

⑤安裝依賴並啓動vite開發服務器

npm i
npm run dev

如果頁面上看到“Hello Vite!”,第一步配置vite就成功了

配置富文本編輯器Quill

①安裝最新版的quill

npm i -S quill

②main.js中引入並配置quill的主題樣式爲snow

當前quill版本是1.3.7,snow主題的樣式文件地址爲https://cdn.quilljs.com/1.3.7/quill.snow.css,我下載到了本地。修改main.js如下:

import Quill from 'quill'
import './quill.snow.css'

const toolbar = [
  ['bold', 'italic', 'underline', 'strike'],
  [{'header': [1, 2, 3, 4, 5, 6, false]}],
  [{'list': 'ordered'}, {'list': 'bullet'}],
  [{'indent': '-1'}, {'indent': '+1'}],
  [{'size': ['small', false, 'large', 'huge']}],
  [{'color': []}, {'background': []}],
  [{'font': []}],
  [{'align': []}],
  ['clean'],
]

const $app= document.querySelector('#app')
const quill = new Quill($app, {
  modules: {
    toolbar,
  },
  theme: 'snow',
})

配置Express服務器

①安裝express依賴

②在src下創建server.js,使用static中間件來託管dist目錄下的靜態文件

③在3000端口啓動服務

const http = require('http')
const path = require('path')
const express = require('express')
const app = express()
const httpServer = http.createServer(app)

app.use(express.static(path.join(__dirname, '../dist')))

httpServer.listen(3000, function () {
  console.log('\x1b[32m%s\x1b[0m', '服務啓動成功')
})

ps:

① 第五行使用node的http模塊創建http服務器,第九行的 server.listen(3000... 可以替換爲 app.listen(3000... ,如果看下express的listen函數源碼,會發現兩者並沒有什麼區別。之所以不直接使用express的listen函數是因爲後面我們的websocket也要使用到http server。

② 第十行的 \x1b[32m%s\x1b[0m 中,%s是佔位符,代表後面的字符串。\x1b[32m和\x1b[0m表示打印前景色是綠色的字符,具體原理見前文:控制檯與終端輸出帶樣式文本原理及實現

修改vite配置文件vite.config.js監聽vite的build行爲

import {defineConfig} from 'vite'

export default defineConfig({
  build: {
    watch: {}
  }
})

開啓node服務

node ./src/server.js

3000端口號上打開頁面,可以看到quill被成功引入。

配置socket.io

①安裝socket.io和socket.io-client依賴

index.html的app改爲editor,並修改main.js:

import Quill from 'quill'
import './quill.snow.css'
import io from 'socket.io-client'

const toolbar = [
  ['bold', 'italic', 'underline', 'strike'],
  [{'header': [1, 2, 3, 4, 5, 6, false]}],
  [{'list': 'ordered'}, {'list': 'bullet'}],
  [{'indent': '-1'}, {'indent': '+1'}],
  [{'size': ['small', false, 'large', 'huge']}],
  [{'color': []}, {'background': []}],
  [{'font': []}],
  [{'align': []}],
  ['clean'],
]

const $editor = document.querySelector('#editor')
const quill = new Quill($editor, {
  modules: {
    toolbar,
  },
  theme: 'snow',
})
const socket = io()
socket.on('welcome', function (word) {
  console.log('welcome:', word)
})
socket.on('broadcast', function (word) {
  console.log('broadcast:', word)
})
// 接收遠程內容同步
socket.on('quill-sync', function (contentStr) {
  contentStr && quill.setContents(JSON.parse(contentStr))
})
// 監聽編輯器內容更新,發送到服務器
quill.on('text-change', function (delta, oldDelta, source) {
  if (source === 'user') {
    const content = quill.getContents()
    socket && socket.emit('quill-sync', JSON.stringify(content))
  }
})

修改server.js:

const http = require('http')
const path = require('path')
const express = require('express')
const app = express()
const httpServer = http.createServer(app)
const {Server: SocketServer} = require('socket.io')
const io = new SocketServer(httpServer)

app.use(express.static(path.join(__dirname, '../dist')))

httpServer.listen(3000, function () {
  console.log('\x1b[32m%s\x1b[0m', '服務啓動成功')
})

let _contentStr = ''

io.on('connection', (socket) => {
  socket.emit('welcome', '你好,' + socket.id)
  socket.broadcast.emit('broadcast', socket.id + '加入')

  // 進入時初始化編輯器內容
  socket.emit('quill-sync', _contentStr)

  // 將收到的內容記錄到服務器,並廣播給其他用戶
  socket.on('quill-sync', function (contentStr) {
    _contentStr = contentStr
    socket.broadcast.emit('quill-sync', contentStr)
  })
})

這時候,當你第一次打開http://localhost:3000的時候,會在控制檯收到歡迎信息;新標籤頁再次打開該地址,原頁面會收到新加入用戶的信息;修改編輯器內容,打開的多個頁面會同時收到同步的內容。

增加房間號與接口鑑權

如果你想把這個頁面共享給其他夥伴,難免就涉及到socket連接的分配與鑑權,這裏爲了方便,直接使用http的Basic鑑權。

1. 安裝sass模塊(即dart sass)

2. 添加src目錄添加main.scss:


* {
  padding: 0;
  margin: 0;
}

html, body {
  width: 100%;
  height: 100%;
}

body {
  display: flex;
  flex-direction: column;
}

.ql-toolbar {
  width: 100%;
  height: auto;
  z-index: 1;
  flex: 0;
}

.ql-editor {
  height: 100%;
  box-sizing: border-box;
}

#status-bar {
  flex: 0;

  #btn-join {
    display: inline-block;
  }

  #num-room {
    display: none;
  }

  #btn-leave {
    display: none;
  }

  &.joined {
    #btn-join {
      display: none;
    }

    #num-room {
      display: inline-block;
    }

    #btn-leave {
      display: inline-block;
    }
  }
}

#editor {
  flex: 1;
}

3. 修改index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>Vite App</title>
</head>
<body>
<div id="status-bar">
    <button id="btn-join">進入房間</button>
    <span id="num-room"></span>
    <button id="btn-leave">離開房間</button>
</div>
<div id="editor"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

4. 修改main.js:

import Quill from 'quill'
import './quill.snow.css'
import './main.scss'
import io from 'socket.io-client'

const toolbar = [
  ['bold', 'italic', 'underline', 'strike'],
  [{'header': [1, 2, 3, 4, 5, 6, false]}],
  [{'list': 'ordered'}, {'list': 'bullet'}],
  [{'indent': '-1'}, {'indent': '+1'}],
  [{'size': ['small', false, 'large', 'huge']}],
  [{'color': []}, {'background': []}],
  [{'font': []}],
  [{'align': []}],
  ['clean'],
]

const $editor = document.querySelector('#editor')
const $statusBar = document.getElementById('status-bar')
const $btnJoin = document.getElementById('btn-join')
const $numRoom = document.getElementById('num-room')
const $btnLeave = document.getElementById('btn-leave')
const quill = new Quill($editor, {
  modules: {
    toolbar,
  },
  theme: 'snow',
})
let socket = null
// 監聽編輯器內容更新,發送到服務器
quill.on('text-change', function (delta, oldDelta, source) {
  if (source === 'user') {
    const content = quill.getContents()
    socket && socket.emit('quill-sync', JSON.stringify(content))
  }
})

// 加入房間
$btnJoin.onclick = async function () {
  const room = prompt('請輸入你要進入的房間:')
  if (room) {
    // 進入 basic 校驗
    const response = await fetch(`/join?room=${room}`)
    const token = await response.text()

    $statusBar.classList.add('joined')
    $numRoom.innerHTML = room

    if (!socket) {
      socket = io({auth: {token}})
    }
    socket.on('welcome', function (word) {
      console.log('welcome:', word)
    })
    socket.on('broadcast', function (word) {
      console.log('broadcast:', word)
    })
    // 接收遠程內容同步
    socket.on('quill-sync', function (contentStr) {
      contentStr && quill.setContents(JSON.parse(contentStr))
    })
  }
}

// 離開房間
$btnLeave.onclick = function () {
  socket.disconnect()
  socket = null
  fetch('logout')
  $statusBar.classList.remove('joined')
}

5. 修改server.js:

const http = require('http')
const path = require('path')
const express = require('express')
const app = express()
const httpServer = http.createServer(app)
const {Server: SocketServer} = require('socket.io')
const io = new SocketServer(httpServer)

app.use(express.static(path.join(__dirname, '../dist')))

httpServer.listen(3000, function () {
  console.log('\x1b[32m%s\x1b[0m', '服務啓動成功')
})

// 每個房間對應的同步數據
const room2content = {}

io.on('connection', (socket) => {
  // socket鑑權
  const authorization = socket.handshake.auth?.token
  const userInfo = authorization ? userDict[authorization] : null
  if (userInfo) {
    userInfo.socket = socket
    socket.join(userInfo.room)
    socket.emit('welcome', `歡迎[${userInfo.name}]加入房間[${userInfo.room}]`)
    socket.broadcast.to(userInfo.room).emit('broadcast', `[${userInfo.name}]加入房間[${userInfo.room}]`)

    // 進入時初始化編輯器內容
    socket.emit('quill-sync', room2content[userInfo.room] || '')

    // 將收到的內容記錄到服務器,並廣播給其他用戶
    socket.on('quill-sync', function (contentStr) {
      room2content[userInfo.room] = contentStr
      socket.broadcast.to(userInfo.room).emit('quill-sync', contentStr)
    })
    socket.on('disconnecting', function () {
      socket.broadcast.to(userInfo.room).emit('broadcast', `[${userInfo.name}]離開房間[${userInfo.room}]`)
      userInfo.room = null
      userInfo.socket = null
    })
  }
})

const userDict = {}

// 登錄/註冊
app.get('/join', function (req, res) {
  const authorization = req.headers.authorization
  const encodeAuthStr = authorization ? authorization.replace('Basic ', '') : ''
  // 進入登錄/註冊
  if (encodeAuthStr && encodeAuthStr !== 'logout') {
    const name = Buffer.from(encodeAuthStr, 'base64').toString().split(':')[0]
    let userInfo = userDict[authorization]
    // 有用戶信息則更新房間號
    if (userInfo) {
      userInfo.room = req.query.room
    }
    // 沒有用戶信息就註冊一個
    else {
      userInfo = {
        name: name,
        room: req.query.room
      }
      userDict[authorization] = userInfo
    }
    res.send(authorization)
  }
  // 請求Basic鑑權
  else {
    res.set({'WWW-Authenticate': 'Basic'})
    res.status(401)
    res.end()
  }
})

// 退出登錄(用於清除 base authorization)
app.get('/logout', function (req, res) {
  res.status(401).end()
})

ps:

base鑑權退出登錄只要接口返回401即可

 完整代碼戳這裏

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章