【在线文档】ot.js demo解析

2,208 阅读2分钟

一、前言

初探在线文档门槛。

项目:

项目地址,运行:

$ npm install
$ npm start

> ot.js-demo@1.0.0 start ot.js-demo
> node server.js

listening on *:3000

先来看看效果: 2021-09-1414-57-29.png

可以看到基本可实现多人在线共同编辑。

通过 webosocket 进行交互。



二、源码分析

项目结构图:

├─server.js             # 服务端
├─index.html            # 入口页面
├─package.json          # 包依赖信息
├─node_modules          # 打包后
└─ot.js                 # 目录
   ├─ajax-adapter.js             #
   ├─client.js                   #
   ├─codemirror-adapter.js       #
   ├─editor-client.js            #
   ├─editor-socketio-server.js   #
   ├─index.js                    #
   ├─selection.js                #
   ├─server.js                   #
   ├─simple-text-operation.js    #
   ├─socketio-adapter.js         #
   ├─text-operation.js           #
   ├─undo-manager.js             #
   └─wrapped-operation.js        #

直接从以下文件入手:

  • server.js
  • index.html

server.js 代码如下:

// 一个服务端框架罢了
var express = require('express');
var app = express();
var http = require('http').Server(app);
var io = require('socket.io')(http);

// localhost:3000 -> index.html
app.get('/', function(req, res){
  res.sendFile(__dirname + '/index.html');
});

// 对外提供访问
app.use('/ot.js', express.static('ot.js'));
app.use('/node_modules', express.static('node_modules'));

// 监听端口
http.listen(3000, function(){
  console.log('listening on *:3000');
});

var EditorSocketIOServer = require('./ot.js/editor-socketio-server.js');
var server = new EditorSocketIOServer("", [], 1);

// socket 连接就调用
io.on('connection', function(socket) {
  server.addClient(socket);
});

server.js 让人摸不着头脑的就是:

  • socket.io :一个面向实时 web 应用的 JavaScript 库,支持 websocket
  • EditorSocketIOServer :只是对 socket 的操作封装,暂且按下不表。

那么就先来了解下 socket.io,再来了解 EditorSocketIOServer


(1)socket.io

直接从 官网 入手。

集成 socket.io 只需要两步:

  1. 服务端添加依赖 socket.io
  2. 客户端在浏览器端使用 socket.ioclient

简单介绍下:

  1. 服务端添加依赖 socket.io

添加依赖:npm install socket.io

const express = require('express');
const app = express();
const http = require('http');
const server = http.createServer(app);
const { Server } = require("socket.io");
const io = new Server(server);

app.get('/', (req, res) => {
  res.sendFile(__dirname + '/index.html');
});

io.on('connection', (socket) => {
  console.log('a user connected');
});

server.listen(3000, () => {
  console.log('listening on *:3000');
});
  1. 客户端在浏览器端使用 socket.ioclient

index.html 中添加对应 js 脚本

<script src="/socket.io/socket.io.js"></script>
<script>
  var socket = io();
</script>

1. 收发事件 Emitting eventssocket.on()

通过 socket.io 发送和接收事件和数据。

数据支持:

  • JSON
  • 二进制数据

接收需要绑定对应的事件类别 message-type

举个栗子:chat message 就是对应事件类别

  1. htmljs 脚本
<script src="/socket.io/socket.io.js"></script>
<script>
  var socket = io();

  var form = document.getElementById('form');
  var input = document.getElementById('input');

  form.addEventListener('submit', function(e) {
    e.preventDefault();
    if (input.value) {
      socket.emit('chat message', input.value);
      input.value = '';
    }
  });
</script>
  1. server 端定义处理对应事件
io.on('connection', (socket) => {
  socket.on('chat message', (msg) => {
    console.log('message: ' + msg);
  });
});

2. 广播事件 emit

广播:将事件发送给任何人。

io.emit('some event', { someProperty: 'some value', otherProperty: 'other value' }); 
// This will emit the event to all connected sockets

举个栗子:

  1. server
io.on('connection', (socket) => {
  socket.on('chat message', (msg) => {
    io.emit('chat message', msg);
  });
});
  1. html
<script>
  var socket = io();

  var messages = document.getElementById('messages');
  var form = document.getElementById('form');
  var input = document.getElementById('input');

  form.addEventListener('submit', function(e) {
    e.preventDefault();
    if (input.value) {
      socket.emit('chat message', input.value);
      input.value = '';
    }
  });

  socket.on('chat message', function(msg) {
    var item = document.createElement('li');
    item.textContent = msg;
    messages.appendChild(item);
    window.scrollTo(0, document.body.scrollHeight);
  });
</script>

3. join 加入房间 & leave 离开

  1. 加入房间 socket.join(room)
io.on("connection", (socket) => {
  console.log(socket.rooms); // Set { <socket.id> }

  socket.join("room1");

  console.log(socket.rooms); // Set { <socket.id>, "room1" }
});
  1. 离开房间 socket.leave(room)
io.on("connection", (socket) => {
  socket.leave("room 237");

  io.to("room 237").emit(`user ${socket.id} has left the room`);
});

4. broadcast 修饰事件发射

官方栗子如下:

io.on("connection", (socket) => {
  // 除了发送方,其他人都能收到
  socket.broadcast.emit("an event", { some: "data" });
});

那么来看下 ot demo 中:

socket.broadcast['in'](this.docId).emit('selection', clientId, selection);
  • 这个 ['in'] 官网也没有示例。黑魔法?
  • 暂且盲猜猜:in 就是字面意思,在对应房间(this.docId)进行广播

(2)EditorSocketIOServer

直接看 editor-socketio-server.js 的源码。

看先引用部分:

// 引入依赖

// 1. Node.js 相关
// events 模块只提供了一个对象: events.EventEmitter。
// EventEmitter 的核心就是事件触发与事件监听器功能的封装。
var EventEmitter     = require('events').EventEmitter;
// util 是一个Node.js 核心模块,提供常用函数的集合,用于弥补核心 JavaScript 的功能 过于精简的不足。
var util             = require('util');


// 2. 下面这些都是 ot 相关的
var TextOperation    = require('./text-operation');
var WrappedOperation = require('./wrapped-operation');
var Server           = require('./server');
var Selection        = require('./selection');

1. const util = require('util'); 使用

util 是一个 Node.js 核心模块,提供常用函数的集合,用于弥补核心 JavaScript 的功能 过于精简的不足。

可看对应 文档

  1. util.inherits(constructor, superConstructor) 是一个实现对象间原型继承的函数。

JavaScript 的面向对象特性是基于原型的,与常见的基于类的不同。 JavaScript 没有提供对象继承的语言级别特性,而是通过原型复制来实现的。

那么再看 editor-socketio-server.js 中有:

// 使 EditorSocketIOServer 继承 Server
util.inherits(EditorSocketIOServer, Server);
// 调用 extend 方法
extend(EditorSocketIOServer.prototype, EventEmitter.prototype);

function extend (target, source) {
  // 遍历
  for (var key in source) {
    // 是否有对应的 key
    // 感觉这块判断就多余了,直接赋值就行
    if (source.hasOwnProperty(key)) {
      target[key] = source[key];
    }
  }
}

2. 定义对象 EditorSocketIOServer

咋一看这代码一脸懵逼:这不是函数嘛?怎么搞的和对象一样。

function EditorSocketIOServer (document, operations, docId, mayWrite) {
  // 1.
  EventEmitter.call(this);
  Server.call(this, document, operations);
  this.users = {};
  this.docId = docId;
  
  // 2. 若 mayWrite 有值,则赋值 mayWrite
  //    若 mayWrite 无值,则赋值 方法
  this.mayWrite = mayWrite || function (_, cb) { cb(true); };
}
  1. 先来看下这个 call 啥意思

答案:继承,调用 super(),调用父类构造方法,赋值父类属性 来个 demo,解释下:demo来源

// 假如有个 EventEmitter 构造器方法
function EventEmitter(){
  this.foo = 'foo';
  this.bar = 'bar';
}

// Dog 构造器方法
function Dog(name) {
    this.name = name;
    EventEmitter.call(this);
}

// 创建 Dog
var newDog = new Dog('furball');
// 可以输出 狗名:name
newDog.name; //furball

// 这就获取父类 EventEmitter 属性
newDog.foo; //foo
newDoc.bar; //bar
  1. 再来看下 function (_, cb) { cb(true); }

旁白:额。。。

// cb 一种写代码风格

// 1. 直接风格写法
function getAvatar(user){
   //...一些程式碼
   return user
}

function display(avatar){
  console.log(avatar)
}

const avatar = getAvatar('eddy')
display(avatar)


// 2. cb:CPS 风格写法
function getAvatar(user, cb){
  //...一些程式碼
   cb(user)
}

function display(avatar){
  console.log(avatar)
}

getAvatar('eddy', display)

(3)index.html 入手

直接从源码入手:

<body>
  <textarea id="note"></textarea>
  <!-- init client -->
  <script>
    <!-- 从 socket.io 这里创建 -->
    var socket = io()
    <!-- 监听 doc的事件 -->
    socket.on('doc', function(data) {
      <!-- 创建编辑器 -->
      var cm = CodeMirror.fromTextArea(document.getElementById('note'), {lineNumbers: true})
      <!-- 设置数据。这里是全量数据?不是全量数据,那必定会指定位置吧! -->
      cm.setValue(data.str)
      <!-- 对 socket.io 的封装,处理一些事件 -->
      var serverAdapter = new ot.SocketIOAdapter(socket)
      <!-- 略 -->
      var editorAdapter = new ot.CodeMirrorAdapter(cm)
      <!-- 创建 editor-client -->
      var client = new ot.EditorClient(data.revision, data.clients, serverAdapter, editorAdapter)
    })
  </script>
</body>



三、ot 算法

(1)可视化演示

根据 可视化文档

网站中两个客户端分别为 AliceBob,这两者之间进行协同操作。

举个栗子,来看下 ot 算法如何处理:

  1. AliceBob 刚进入此文档,初始文档为 Lorem ipsum
  2. Alice 在语句末尾写入 y,并发送给服务端
  3. Bob 删除语句末尾的 m ,并发送给服务端
  4. AliceBob 收到服务端消息

2021-09-2211-18-06.png

接收到数据后:

2021-09-2211-20-15.png

服务端接收操作请求数据先后:Alice(蓝色)、Bob (红色)。

Alice 操作过程:

  1. 发送操作事件
  2. 服务器接收事件,同步 Alice(蓝色)事件
  3. 服务器同步 Bob(红色)事件

Bob 操作过程:

  1. 发送操作事件
  2. 服务器同步 Alice (蓝色)事件
  3. 服务器同步 Bob(蓝色)事件

基于版本进行操作。


(2)源码解析

ot文档

操作样例,如下:

// 定义两种操作
// 1. 先保留(移动光标),再插入
var operation0 = new ot.Operation()
  .retain(11)
  .insert(" dolor");
// 2. 先删除,再保留
var operation1 = new ot.Operation()
  .delete("lorem ")
  .retain(11);

// 初始数据
var str0 = "lorem ipsum";

// 应用操作
var str1 = operation0.apply(str0);  // "lorem ipsum dolor"
var str2a = operation1.apply(str1); // "ipsum dolor"

ot 算法,主要操作有:

  • retain保持:移动光标到某个位置,输入正整数
  • insert插入:在光标之后插入字符串
  • delete删除:删除光标之后的字符串

交互中,服务端示例:

var server = new ot.Server("lorem ipsum");
server.broadcast = function (operation) {
  // you have to broadcast the operation to all connected
  // clients including the one that the operation came from
};

// when you receive an operation as a JSON string from one of the clients, do:
function onReceiveOperation (json) {
  var operation = ot.Operation.fromJSON(JSON.parse(json));
}

交互中,客户端示例:

var client = new ot.Client(0); // the client joins at revision 0

client.applyOperation = function (operation) {
  // apply the operation to the editor, e.g.
  // operation.applyToCodeMirror(cm);
};

client.sendOperation = function (operation) {
  // send the operation to the server, e.g. with ajax:
  $.ajax({
    url: '/operations',
    type: 'POST',
    contentType: 'application/json',
    data: JSON.stringify(operation)
  });
};

// 用户改变数据时
function onUserChange (change) {
  var operation = client.createOperation(); // has the right revision number
  // initialize operation here with for example operation.fromCodeMirrorChange
  client.applyClient(operation);
}

// 当接收到服务端数据时
function onReceiveOperation (json) {
  var operation = ot.Operation.fromJSON(JSON.parse(json));
  client.applyServer(operation);
}

ot核心代码,如下:

  // Call this method whenever you receive an operation from a client.
  Server.prototype.receiveOperation = function (revision, operation) {
    if (revision < 0 || this.operations.length < revision) {
      throw new Error("operation revision not in history");
    }
    // Find all operations that the client didn't know of when it sent the
    // operation ...
    var concurrentOperations = this.operations.slice(revision);

    // ... and transform the operation against all these operations ...
    var transform = operation.constructor.transform;
    for (var i = 0; i < concurrentOperations.length; i++) {
      operation = transform(operation, concurrentOperations[i])[0];
    }

    // ... and apply that on the document.
    this.document = operation.apply(this.document);
    // Store operation in history.
    this.operations.push(operation);

    // It's the caller's responsibility to send the operation to all connected
    // clients and an acknowledgement to the creator.
    return operation;
  };

如上,可分为三个步骤:

  1. 获取当前所有操作
  2. 转换:根据当前服务器的操作,对接收到的操作进行转换
  3. 存储操作