协同编辑
让多个用户打开同一个 Base,共同修改表、字段、记录和视图。用户可以在不同视图中工作,修改仍作用于同一份文档。
接入步骤
- 按协同集成运行协同示例,接入登录、权限和数据库。
- 在已有 Bases 编辑器中加入 Collaboration Client 配置。前端配置方式见 协同快速开始,但后端创建和保存的文档类型应为 Bases,不能直接使用示例中的 Sheets 数据。
- 由业务后端创建 Base 文档,将返回的 ID 交给前端加载。
打开服务端 Base
先设置当前用户,再加载文档;详见协同集成。
TypeScript
import '@univerjs-pro/bases/facade'import { UserManagerService } from '@univerjs/core'import '@univerjs-pro/collaboration-client/facade'univer.__getInjector().get(UserManagerService).setCurrentUser({ userID, name, avatar })const base = await univerAPI.getCollaboration().loadBaseAsync('your-base-id')if (!base) throw new Error('Base could not be loaded')重新打开或分享文档时使用同一个 ID。不要再用 createBase() 创建本地空白文档来代替服务端加载。
通过同步状态、重发与冲突恢复实现保存提示,并在刷新前保护尚未确认的修改。
移除内置 URL 加载协同文档
注意事项
从 1.2.0 版本开始,Univer 移除了内置的 URL 协同文档加载逻辑,不再自动读取 URL 中的 unit 和 type 参数来加载协同文档。
如果你的应用依赖这一行为,可以通过自定义插件恢复。
接入自定义插件
将下方四个文件按以下目录结构复制到项目中:
text
custom-url-load/├── plugin.ts├── controllers/│ └── data-loader.controller.ts└── services/ ├── url.service.ts └── web-url.service.ts在完成编辑器和协同插件初始化后,先设置当前用户,再注册 CustomUrlLoadPlugin。
TypeScript
import { UserManagerService } from '@univerjs/core'import { CustomUrlLoadPlugin } from './custom-url-load/plugin'univer.__getInjector().get(UserManagerService).setCurrentUser({ userID, name, avatar })univer.registerPlugin(CustomUrlLoadPlugin)注册该插件后,由插件负责加载文档,无需再为同一文档调用 loadBaseAsync()。如果应用已经通过 Facade 加载文档,可以继续使用上一节的方式,并根据业务需要自行处理 URL。
完整插件代码
TypeScript
import { UniverCollaborationClientPlugin } from '@univerjs-pro/collaboration-client'import { DependentOn, Inject, Injector, Plugin } from '@univerjs/core'import { CollaborationDataLoaderController } from './controllers/data-loader.controller'import { IURLService } from './services/url.service'import { WebURLService } from './services/web-url.service'@DependentOn(UniverCollaborationClientPlugin)export class CustomUrlLoadPlugin extends Plugin { static override pluginName = 'CUSTOM_URL_LOAD_PLUGIN' constructor( _config: undefined, @Inject(Injector) protected override readonly _injector: Injector ) { super() } override onStarting(): void { this._injector.add([IURLService, { useClass: WebURLService }]) this._injector.add([CollaborationDataLoaderController]) this.disposeWithMe(this._injector.get(IURLService)) this.disposeWithMe(this._injector.get(CollaborationDataLoaderController)) }}TypeScript
import { DataLoaderService } from '@univerjs-pro/collaboration-client'import { ILogService, Inject, RxDisposable } from '@univerjs/core'import { IURLService } from '../services/url.service'/** * 通过 URL 参数加载协同文档。 */export class CollaborationDataLoaderController extends RxDisposable { constructor( @IURLService private readonly _urlService: IURLService, @ILogService private readonly _logService: ILogService, @Inject(DataLoaderService) private readonly _dataLoaderService: DataLoaderService ) { super() const unitId = this._urlService.getParam('unit') const type = this._urlService.getParam('type') if (!unitId || !type) { return } this._dataLoaderService.loadUnit(unitId, Number(type)) .catch((error: unknown) => { this._logService.error('[CollaborationDataLoaderController]', error) }) }}TypeScript
import type { IDisposable } from '@univerjs/core'import type { Observable } from 'rxjs'import { createIdentifier } from '@univerjs/core'export interface IURLService extends IDisposable { getParam: (key: string) => string | undefined setParam: (key: string, value: string, useReplace?: boolean) => void removeParam: (key: string, useReplace?: boolean) => void readonly urlChange$: Observable<void>}export const IURLService = createIdentifier<IURLService>('demo.custom-url-load.url-service')TypeScript
import type { IURLService } from './url.service'import { RxDisposable } from '@univerjs/core'import { fromEvent, map, shareReplay, takeUntil } from 'rxjs'export class WebURLService extends RxDisposable implements IURLService { readonly urlChange$ = fromEvent(window, 'popstate').pipe( map(() => undefined), takeUntil(this.dispose$), shareReplay({ bufferSize: 1, refCount: true }) ) getParam(key: string): string | undefined { return new URL(window.location.href).searchParams.get(key) ?? undefined } setParam(key: string, value: string, useReplace = false): void { const url = new URL(window.location.href) url.searchParams.set(key, value) this._updateUrl(url, useReplace) } removeParam(key: string, useReplace = false): void { const url = new URL(window.location.href) url.searchParams.delete(key) this._updateUrl(url, useReplace) } private _updateUrl(url: URL, useReplace: boolean): void { if (useReplace) { window.history.replaceState(window.history.state, '', url.toString()) } else { window.history.pushState(window.history.state, '', url.toString()) } }}验证同步和保存
用两个独立浏览器会话加载同一份文档,在一端添加记录或修改字段,确认另一端能收到变化;刷新页面并重启服务后,修改仍应保留。
查看者和无权限用户都需要由服务端校验。本地只读设置只控制编辑器行为。导入导出、评论和历史记录还需要各自的后端功能,见导入导出、评论和历史记录。
你觉得这篇文档如何?