阅读视图

发现新文章,点击刷新页面。

救命!ES6入门到精通,前端小白也能秒上手

谁懂啊家人们!前端入门绕不开ES6,可网上的教程要么太晦涩,要么代码零散,新手看了直接劝退😭

其实ES6根本没那么难!它不是全新的语言,只是JavaScript的“升级补丁”——把ES5里繁琐的写法简化,新增了超多实用功能,学会它,写代码效率直接翻倍,面试也能轻松拿捏!

今天就结合实战代码,把ES6核心知识点拆解得明明白白,从基础到进阶,小白也能跟着敲、跟着会,收藏这一篇就够了,再也不用东拼西凑找资料!

一、先搞懂:ES和JS到底是什么关系?(新手必看)

很多小白刚入门就被“ES6”“JavaScript”搞懵,其实一句话就能说清:

ES 是 ECMAScript 的简写,是 JavaScript 的“核心语法标准”;而 JS 由 3 部分组成:ECMAScript(核心)+ DOM(文档对象模型)+ BOM(浏览器对象模型)。简单说,ES 就是 JS 的“灵魂”,学 JS 必学 ES!

以前我们学的大多是 ES5 语法,而 ES6 及以后的版本,做了大量优化,解决了 ES5 的很多痛点(比如变量提升、代码冗余),现在前端开发几乎全员用 ES6+ 写法,不学真的会被淘汰!

💡 开发工具推荐:VS Code(免费又强大),必装 2 个插件:

  • View in Browser:一键在浏览器中查看效果
  • JavaScript (ES6) code snippets:ES6 代码片段,一键生成,提升编码速度

二、ES6 核心知识点(实操为王,代码可直接复制)

这部分是重点!每个知识点都配了「代码示例+通俗解释」,敲一遍就懂,建议边看边练,记得更牢~

1. 变量声明:let/const 替代 var(彻底解决变量提升坑)

ES5 里我们用 var 声明变量,会有“变量提升”“重复声明”“全局污染”三个大坑,而 ES6 的 let 和 const 直接解决了这些问题,用法超简单!

✨ let 用法(声明局部变量)

// 1. 不允许未定义就使用(避免变量提升)
// console.log(k); // 报错:Uncaught ReferenceError: k is not defined

// 2. 不允许重复声明
let k = 10;
// let k = 101; // 报错:Uncaught SyntaxError: Identifier 'k' has already been declared

// 3. 块级作用域(只在当前代码块有效)
for (let j = 0; j < 5; j++) {
  console.log("循环里的j:" + j); // 正常输出 0-4
}
// console.log("循环外的j:" + j); // 报错:j is not defined

✨ const 用法(声明常量)

// 声明常量,指向的内存地址不能修改
const x = 2;
// x = 991; // 报错:Uncaught TypeError: Assignment to constant variable.

// 注意:如果常量是对象/数组,内部属性可以修改
const obj = { name: "jspang" };
obj.name = "技术胖"; // 正常生效,不报错

小技巧:能⽤ const 就⽤ const,需要修改的变量再⽤ let,避免全局污染!

2. 变量解构赋值:简化赋值,少写冗余代码

以前给多个变量赋值,要写多行代码,ES6 的解构赋值,一行就能搞定,还支持数组、对象、字符串解构,超实用!

✨ 数组解构

// ES5 写法
let a = 0; let b = 1; let c = 2;

// ES6 解构写法(简洁!)
let [a, b, c] = [1, 2, 3];
console.log(a); // 1
console.log(b); // 2
console.log(c); // 3

// 嵌套数组解构
let [a, [b, c], d] = [1, [2, 3], 4];
console.log(a); // 1,b:2,c:3,d:4

✨ 对象解构(最常用,重点记!)

// 核心:变量名必须和对象属性名一致
let { foo, bar } = { foo: 'JSPang', bar: '技术胖' };
console.log(foo + bar); // 输出:JSPang技术胖

// 圆括号用法(当变量已经声明时)
let foo;
({ foo } = { foo: 'JSPang' }); // 必须加圆括号,否则报错
console.log(foo); // JSPang

✨ 解构默认值(避免 undefined)

// 当解构的值不存在时,使用默认值
let [a, b = "JS"] = ['张三'];
console.log(a + b); // 张三JS

// 注意:undefined 和 null 的区别
let [a, b = "JSPang"] = ['技术胖', undefined]; // undefined 用默认值
console.log(a + b); // 技术胖JSPang

let [a, b = "JSPang"] = ['技术胖', null]; // null 不用默认值
console.log(a + b); // 技术胖null

3. 字符串扩展:新增方法,简化字符串操作

ES6 给字符串新增了 includes()、startsWith()、endsWith()、repeat() 等方法,替代了传统的 indexOf(),写法更简洁,语义更清晰!

let str = "https://www.baidu.com/";

// 1. includes():判断是否包含指定字符串(返回true/false)
console.log(str.includes("www")); // true
console.log(str.includes("yyy")); // false

// 2. startsWith():判断是否以指定字符串开头
console.log(str.startsWith("https")); // true
console.log(str.startsWith("baidu", 12)); // 从第12位开始,是否以baidu开头?true

// 3. endsWith():判断是否以指定字符串结尾
console.log(str.endsWith("com")); // false(原字符串结尾是/)
console.log(str.endsWith("www", 11)); // 前11位是否以www结尾?true

// 4. repeat():复制字符串
console.log('jspang|'.repeat(3)); // jspang|jspang|jspang|

4. 数组扩展:新增方法,搞定数组操作

ES6 给数组新增了 Array.from()、Array.of()、find()、filter()、map() 等方法,再也不用手动写循环,效率翻倍!

// 1. Array.from():将类数组(如JSON)转为真正的数组
let json = {
  '0': 'jspang',
  '1': '技术胖',
  '2': '大胖逼逼叨',
  length: 3
};
let arr = Array.from(json);
console.log(arr); // ['jspang', '技术胖', '大胖逼逼叨']

// 2. Array.of():将任意值转为数组
let arr1 = Array.of(3, 4, 5, 6);
console.log(arr1); // [3,4,5,6]

// 3. find():查找数组中第一个满足条件的元素
let arr2 = [1,2,3,4,5,6];
console.log(arr2.find(value => value > 5)); // 6

// 4. filter():过滤数组(返回满足条件的新数组)
let num = [1, 5, 5, 9];
let num1 = num.filter(x => x != 5); // 过滤掉5
console.log(num1); // [1,9]

// 5. map():映射数组(对每个元素做处理,返回新数组)
let arr3 = ['jspang','技术胖','前端教程'];
console.log(arr3.map(x => 'web')); // ['web', 'web', 'web']

5. 扩展运算符(...):万能简化神器

扩展运算符(...)是 ES6 最常用的语法之一,能拆分数组、对象,简化函数参数传递,解决数组浅拷贝问题,用法超灵活!

// 1. 简化函数参数
let add = (...c) => {
  let sum = 0;
  for (const num of c) {
    sum += num;
  }
  return sum;
};
let num = [1, 5, 5, 9];
console.log(add(...num)); // 20(相当于add(1,5,5,9))

// 2. 数组浅拷贝(避免修改新数组影响原数组)
let arr1 = ['www','jspang','com'];
let arr2 = [...arr1]; // 浅拷贝
arr2.push('shengHongYu');
console.log(arr1); // ['www','jspang','com'](原数组不变)
console.log(arr2); // ['www','jspang','com','shengHongYu']

6. 箭头函数:简化函数写法,告别this坑

ES6 的箭头函数,把 function 关键字简化成 =>,代码更简洁,还解决了传统函数中 this 指向混乱的问题,前端面试高频考点!

// ES5 函数写法
function fun1(x, y) {
  return x + y;
}

// ES6 箭头函数写法(简化!)
let fun1 = (x, y) => x + y; // 只有一句执行语句,可省略{}和return
console.log(fun1(2, 6)); // 8

// 带默认值的箭头函数
let fun3 = (x, y = 1) => x + y;
console.log(fun3(4)); // 5(y默认值为1)

// 注意:箭头函数没有自己的this,this指向外层作用域
const obj = {
  name: "技术胖",
  say: () => {
    console.log(this.name); // undefined(this指向window,不是obj)
  }
};
obj.say();

7. Set/WeakSet:数组去重神器

Set 是 ES6 新增的数据结构,和数组类似,但不允许有重复值,天生适合数组去重,还有 add()、delete()、has() 等方法,用法简单!

// 1. 声明Set(自动去重)
let setArr = new Set(['jspang','技术胖','web','jspang']);
console.log(setArr); // Set {"jspang", "技术胖", "web"}(重复值被自动过滤)

// 2. 常用方法
setArr.add('前端职场'); // 新增元素
setArr.delete('jspang'); // 删除元素
console.log(setArr.has('技术胖')); // true(判断是否存在)
setArr.clear(); // 清空Set

// 3. 数组去重(实战常用)
let arr = [1,2,2,3,3,3];
let newArr = [...new Set(arr)];
console.log(newArr); // [1,2,3]

8. Map:比对象更灵活的键值对

Map 和对象类似,都是键值对结构,但 Map 的键可以是任意类型(数字、数组、函数、对象),而对象的键只能是字符串/ Symbol,灵活性更高!

// 声明Map并添加键值对
const map = new Map();
let num = 123;
let arr = [1,2,3];
map.set(num, "数字键");
map.set(arr, "数组键");
map.set('name', "技术胖");

// 常用方法
console.log(map.get(num)); // 数字键(获取值)
console.log(map.has('name')); // true(判断键是否存在)
map.delete(arr); // 删除指定键值对
console.log(map.size); // 2(获取键值对数量)
map.clear(); // 清空Map

9. Promise:解决回调地狱,异步编程神器

以前写异步代码(如请求接口、定时器),会出现“回调嵌套回调”的情况,也就是回调地狱,代码混乱难维护,而 Promise 完美解决了这个问题!

// 实战案例:模拟异步操作(洗菜做饭→吃饭→收拾桌子)
let state = 1; // 1表示成功,0表示失败

// 第一步:洗菜做饭
function step1(resolve, reject) {
  console.log('1.开始-洗菜做饭');
  if (state == 1) {
    resolve('洗菜做饭--完成'); // 成功,执行then
  } else {
    reject('洗菜做饭--出错'); // 失败,执行catch
  }
}

// 第二步:吃饭
function step2(resolve, reject) {
  console.log('2.开始-坐下来吃饭');
  if (state == 1) {
    resolve('坐下来吃饭--完成');
  } else {
    reject('坐下来吃饭--出错');
  }
}

// 第三步:收拾桌子
function step3(resolve, reject) {
  console.log('3.开始-收拾桌子洗碗');
  if (state == 1) {
    resolve('收拾桌子洗碗--完成');
  } else {
    reject('收拾桌子洗碗--出错');
  }
}

// 链式调用,避免回调地狱
new Promise(step1)
  .then(val => {
    console.log(val);
    return new Promise(step2); // 执行下一步
  })
  .then(val => {
    console.log(val);
    return new Promise(step3);
  })
  .then(val => {
    console.log(val);
  })
  .catch(err => {
    console.log(err); // 捕获任意一步的错误
  });

10. Class:面向对象编程,简化构造函数

ES6 引入了 Class(类)的概念,简化了 ES5 中构造函数的写法,让面向对象编程更直观,还支持继承,适合大型项目开发!

// 声明类
class Coder {
  // 构造函数(初始化属性)
  constructor(a, b) {
    this.a = a;
    this.b = b;
  }

  // 类的方法
  name(val) {
    console.log(val);
    return val;
  }

  skill(val) {
    console.log(this.name('jspang') + ':' + 'Skill:' + val);
  }

  add() {
    return this.a + this.b;
  }
}

// 实例化类
let jspang = new Coder(1, 2);
jspang.name('jspang'); // 输出:jspang
jspang.skill('web'); // 输出:jspang:Skill:web
console.log(jspang.add()); // 3

// 类的继承(extends关键字)
class htmler extends Coder {}
let pang = new htmler;
pang.name('技术胖'); // 输出:技术胖(继承了Coder类的方法)

三、ES6 必背面试考点(小白必记)

学会以上知识点,日常开发足够用了,但如果要面试,这几个考点一定要记牢,避免踩坑!

  • let/const 和 var 的区别(变量提升、重复声明、块级作用域)
  • 箭头函数和普通函数的区别(this 指向、arguments、不能作为构造函数)
  • Promise 的三种状态(pending、fulfilled、rejected)及链式调用
  • Set 和 Array 的区别(去重、无索引)
  • Map 和对象的区别(键的类型、遍历方式)

四、最后说几句掏心窝的话

很多小白觉得 ES6 难,其实是因为一开始就啃复杂的概念,忽略了“实操”。ES6 的核心是“简化代码、提高效率”,所有知识点都围绕这个核心,只要多敲代码、多练案例,3-7 天就能掌握核心用法!

这篇文章整理了 ES6 最常用、最核心的知识点,代码可直接复制练习,建议收藏起来,遇到不会的就翻一翻,慢慢就熟练了~

如果觉得有用,记得点赞+收藏,关注我,后续更新更多前端小白干货,一起从入门到精通!💪

智元机器人、中天科技等在南通成立机器人新公司

36氪获悉,爱企查App显示,近日,中天机器人(南通)有限公司成立,法定代表人为谢书鸿,注册资本2000万元人民币,经营范围包括智能机器人销售、智能机器人的研发、工业机器人销售、工业机器人制造等。股东信息显示,该公司由中天科技、智元机器人关联公司智元创新(上海)科技股份有限公司、北京辉羲智能信息技术有限公司等共同持股。

nestjs实战 - 拦截器,统一处理接口请求与响应结果

在之前的篇章中介绍了 拦截器的基本概念、使用方法、使用场景;

本节主要从实战层面开发一个通用功能:统一处理接口请求与响应结果

需求:

  • 统一处理接口请求与响应结果
  • 可选配置(部分接口如果不需要统一处理 可配置)

第一步,全局注入拦截器

首先创建一个 transform.interceptor.ts 文件,并全局注入:

/// app.module.ts
// 省略其它代码
// 主要代码
import { APP_INTERCEPTOR } from '@nestjs/core';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';

@Module({
  // ...
  providers: [
    // 全局注入拦截器,它会作用到所有路由上
    { provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, 
  ],
})
export class AppModule {}

第二步,拦截器功能实现

需要注意的点,我们需要处理

  • 请求参数(前置拦截器)
  • 响应结果(后置拦截器)

在之前的章节中也介绍了这两个概念。

// 首先需要下载两个相关依赖
pnpm add fastify qs  

transform.interceptor.ts

实现拦截器 transform.interceptor.ts 内部逻辑:

import {
  NestInterceptor,
  CallHandler,
  ExecutionContext,
  Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core'
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

import type { FastifyRequest } from 'fastify'
import qs from 'qs';

import { ResponseModel } from '../mode/response.mode';
import { BYPASS_KEY } from '../decorators/bypass.decorator';


/**
 * 响应拦截器
 * 用于处理响应数据
 * 可以用于处理响应数据,如添加响应头,添加响应体等
 */
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor {
  constructor(private readonly reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<ResponseModel<T>> {
    // ==========================
    // 【阶段 1:控制器执行之前】
    // ==========================
    // 这里的代码会立即同步执行。
    // 此时请求刚到达拦截器,还没进控制器。

    // ✅功能1:获取是否需要跳过拦截器
    const bypass = this.reflector.get<boolean>(
      BYPASS_KEY,
      context.getHandler(),
    )

    // 如果在这里直接 return 一个 Observable (例如 return of({error: 'blocked'}))
    // 而不调用 next.handle(),控制器将永远不会执行(短路)。
    // 调用 next.handle() 启动控制器逻辑
    // 它返回一个 Observable,代表控制器未来的执行结果(流)
    if (bypass)
      return next.handle()
    
    // ✅功能2:获取请求对象
    const http = context.switchToHttp()
    const request = http.getRequest<FastifyRequest>()
    // 处理 query 参数,将数组参数转换为数组,如:?a[]=1&a[]=2 => { a: [1, 2] }
    request.query = qs.parse(request.url.split('?').at(1))


    // ✅功能3:调用控制器逻辑
    const response$ = next.handle(); 
    // 【阶段 2:控制器执行之后】
    // ==========================
    // 这里的代码不会立即执行!
    // 它们被注册为 RxJS 的“操作符”,只有当控制器执行完毕并产生数据时,流才会流动到这里。
    return response$.pipe(
      map((data) => {
        console.log('data', data);
        return ResponseModel.success(data);
      }),
    );
  }
}

response.mode.ts

它是生成 响应数据 的一个构造函数;

import { HttpStatus } from "@nestjs/common";

export class ResponseModel<T = any> {
  code: number;
  message: string;
  data?: T;

  constructor(code: number, message: string, data?: T) {
    this.code = code;
    this.message = message;
    this.data = data ?? undefined;
  }

  static success<T>(data?: T) {
    return new ResponseModel(HttpStatus.OK, 'success', data);
  }

  static error(code: number, message: string) {
    return new ResponseModel(code, message, null);
  }
}

bypass.decorator.ts

配置:是否使用 - 拦截器统一响应数据结构功能,亦可解释为 此拦截器功能的开关

import { SetMetadata } from '@nestjs/common';

export const BYPASS_KEY = '__bypass_key__';

/**
 * 当不需要转换成基础返回格式时添加该装饰器
 */
export const Bypass = () => SetMetadata(BYPASS_KEY, true);

SetMetadata

在 NestJS 中,@SetMetadata() 是一个核心装饰器,用于向路由处理器(Controller 中的方法)或控制器类附加自定义的元数据(Metadata)。简单来说,它允许你给代码“打标签”,这些标签可以在运行时被读取,从而实现灵活、声明式的逻辑控制,例如权限校验、日志记录或缓存策略等。

1. 设置元数据

你可以直接在控制器或其方法上使用 @SetMetadata('key', value) 来设置元数据。

  • key: 一个字符串,作为元数据的唯一标识。
  • value: 任意类型的值,是你想要存储的数据。

示例:

import { Controller, Get, SetMetadata } from '@nestjs/common';

@Controller('cats')
export class CatsController {

  // 为单个方法设置元数据
  @Get()
  @SetMetadata('roles', ['admin']) // key 是 'roles', value 是 ['admin']
  findAll() {
    return 'This action returns all cats';
  }

  // 也可以为整个控制器设置元数据
  @SetMetadata('isPublic', true)
  @Get('public')
  findPublic() {
    return 'This is a public route';
  }
}

设置元数据的最佳实践,如上文中我们的写法,通过一个自定义装饰,为控制器 或 方法 设置。

2. 获取元数据

设置的元数据本身是静态的,它的价值在于在运行时被动态读取。这通常在 守卫(Guards)拦截器(Interceptors)管道(Pipes) 中完成,通过注入 Reflector 辅助类来实现。

Reflector 提供了多种方法来读取元数据,最常用的是 get()

以上文中拦截器为例,获取元数据:

// ...
import KEY_NAME from '../****'

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor {
  constructor(private readonly reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<ResponseModel<T>> {
    const bypass = this.reflector.get<boolean>(
      KEY_NAME,
      context.getHandler(), // 获取控制器方法的元数据
    )
  }
}

第三步,使用

测试一下,我们再users.controller.ts 中测试使用

import { Bypass } from '~/common/decorators/bypass.decorator';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  @Bypass() // 过滤全局统一响应数据拦截器
  findAll() {
    return this.usersService.findAll();
  }
}

注意:

const bypass = this.reflector.get<boolean>(
  BYPASS_KEY,
  context.getHandler(), // 获取控制器方法的元数据
)

@Bypass 装饰器只能作用在 路由处理方法 上。

总结:

以上就完成了 统一处理接口请求与响应结果 功能的开发,顺便让我们熟悉了 拦截器的用法;

再次回顾一下拦截器的使用场景:

  • 请求参数统一处理:格式转换
  • 响应数据统一 格式化
  • 响应缓存
  • 超时处理
  • 数据序列化/脱敏

业务系统深度集成:基于OnlyOffice中国版连接器实现合同生成、AI写作与报表自动化

业务系统深度集成:基于OnlyOffice中国版连接器实现合同生成、AI写作与报表自动化

一、为什么需要连接器

在大多数企业系统中,文档编辑器只是一个"嵌入式组件"——用户打开、编辑、保存,仅此而已。但真实业务场景中,我们往往需要从外部系统控制文档内容:

  • 合同系统需要将业务数据自动填入合同模板
  • AI写作系统需要将生成的内容插入到光标位置
  • 报表系统需要将统计数据写入Excel并自动生成图表
  • 审批系统需要在文档中自动插入审批意见和签章

这些需求的共同特点是:操作文档的主体不是用户,而是外部系统

OnlyOffice 提供了 连接器(Connector) 机制来满足这类需求。中国版完整实现了官方连接器的全部功能,兼容官方 JSAPI,并可与用户只读模式动态权限切换等增强功能配合使用,构建出更强大的业务集成方案。

中国版连接器增强能力

  • 兼容官方 Automation API,支持 Word/Excel/PPT 全文档类型操作
  • 可与用户只读模式配合:用户无法手动编辑,但连接器可操作文档
  • 支持动态权限切换:运行时通过连接器修改用户权限
  • 支持细粒度文档操作:段落、Run、样式、图表等均可操控

二、连接器基础

2.1 什么是连接器

连接器是 OnlyOffice 文档编辑器提供的 JavaScript API 接口,允许外部代码(宿主页面)对正在编辑的文档执行操作。它与插件(Plugin)拥有相同的底层接口,但使用方式更灵活:

  • 插件:需要打包部署到 documentserver 内部,通过编辑器内的插件菜单激活
  • 连接器:直接在宿主页面的 JavaScript 中调用,无需部署任何文件

对于业务系统集成来说,连接器是更合适的选择

2.2 创建连接器

在初始化编辑器后,通过 createConnector 方法获取连接器实例:

// 初始化编辑器
const docEditor = new DocsAPI.DocEditor("placeholder", config);

// 创建连接器
const connector = docEditor.createConnector();

2.3 核心方法

连接器提供两个核心方法:

callCommand —— 在文档上下文中执行代码:

connector.callCommand(function () {
    // 这里的代码运行在文档编辑器内部
    // 可以使用 Api 对象操作文档
    var oDocument = Api.GetDocument();
    // ...
});

executeMethod —— 调用编辑器提供的方法:

connector.executeMethod("InsertTextToCursor", ["Hello World"]);

两者的区别在于:callCommand 内的函数运行在编辑器沙箱中,可以调用完整的文档操作 API;executeMethod 是对常用操作的封装,调用更简洁。

注意callCommand 中的函数是序列化后传递到编辑器内部执行的,因此不能引用外部变量。需要传递数据时,可以通过函数返回值或事件机制。

三、场景一:合同模板自动填充

3.1 业务需求

某企业合同管理系统的需求:

  • 合同使用标准 Word 模板,包含固定条款和可变字段
  • 业务人员在系统中填写合同要素(甲乙方、金额、期限等)
  • 系统自动将数据填入模板对应位置
  • 用户在编辑器中只能查看结果,不能手动编辑

3.2 模板设计

在 Word 模板中,使用特定格式的占位符标记可变内容,例如:

甲方:{{partyA}}
乙方:{{partyB}}
合同金额:人民币 {{amount}} 元整
合同期限:{{startDate}} 至 {{endDate}}

3.3 技术实现

第一步:配置编辑器

使用用户只读模式,确保用户不能手动编辑,但连接器可以操作文档:

const config = {
  document: {
    fileType: "docx",
    key: contractKey,
    title: "采购合同-2026-0412",
    url: templateDownloadUrl,
    permissions: {
      edit: true,
      copy: true,
      copyOut: false,
      print: true
    }
  },
  editorConfig: {
    mode: "edit",
    customization: {
      readOnly: true,  // 用户只读模式
      waterMark: {
        value: `${currentUser.name}\\n合同预览`,
        fillstyle: "rgba(192, 192, 192, 0.2)",
        font: "14px SimHei",
        rotate: -30,
        opacity: 0.2
      }
    }
  }
};

第二步:获取业务数据并填充

当用户在业务表单中填写完合同要素后,通过连接器将数据写入文档:

// 业务数据
const contractData = {
  partyA: "北京某某科技有限公司",
  partyB: "上海某某信息技术有限公司",
  amount: "壹佰贰拾叁万肆仟伍佰陆拾柒",
  amountNum: "1,234,567.00",
  startDate: "2026年04月12日",
  endDate: "2027年04月11日",
  signDate: "2026年04月12日"
};

// 通过连接器填充数据
function fillContract(data) {
  const connector = docEditor.createConnector();

  // 将数据序列化后传入
  const jsonData = JSON.stringify(data);

  connector.callCommand(function () {
    // 在文档上下文中执行
    var oDocument = Api.GetDocument();
    var aElements = oDocument.GetAllContentControls();

    // 如果使用内容控件方式
    for (var i = 0; i < aElements.length; i++) {
      var tag = aElements[i].GetTag();
      // 根据 tag 匹配字段并替换
    }
  });

  // 也可以使用搜索替换方式
  connector.callCommand(function () {
    var oDocument = Api.GetDocument();

    // 使用 SearchAndReplace 方法
    var oSearchData = {
      searchString: "{{partyA}}",
      replaceString: "北京某某科技有限公司",
      matchCase: true
    };

    oDocument.SearchAndReplace(oSearchData);
  });
}

第三步:逐字段替换的完整实现

实际项目中,建议封装一个通用的模板填充方法:

function fillTemplate(connector, fieldMap) {
  const entries = Object.entries(fieldMap);

  // 由于 callCommand 内部不能引用外部变量
  // 需要逐个字段调用,或者将数据编码到函数体中
  entries.forEach(([placeholder, value]) => {
    // 动态构造函数字符串
    const script = `
      var oDocument = Api.GetDocument();
      oDocument.SearchAndReplace({
        searchString: "{{${placeholder}}}",
        replaceString: "${value.replace(/"/g, '\\"')}",
        matchCase: true
      });
    `;

    connector.callCommand(new Function(script));
  });
}

// 使用
fillTemplate(connector, {
  partyA: contractData.partyA,
  partyB: contractData.partyB,
  amount: contractData.amount,
  amountNum: contractData.amountNum,
  startDate: contractData.startDate,
  endDate: contractData.endDate
});

3.4 用户只读模式详解

用户只读模式是中国版特有的功能,可以实现"用户不可编辑,但连接器可操作文档"的效果。

与普通只读模式的区别

模式 用户能否编辑 连接器能否操作 适用场景
普通只读(mode: view) 纯预览场景
用户只读(readOnly: true) 合同生成、公文套打等

配置要点

{
  "editorConfig": {
    "customization": {
      "readOnly": true
    },
    "permissions": {
      "edit": true
    },
    "mode": "edit"
  }
}

三个字段必须同时配置:mode 设为 editpermissions.edit 设为 truecustomization.readOnly 设为 true

注意:用户只读模式为高级版功能,目前仅支持 Word/Excel/PPT 的 PC 模式

3.5 关键注意事项

  • 模板占位符应使用不易与正文冲突的格式(如 {{fieldName}}
  • 替换操作完成后,建议调用保存接口生成最终文档
  • 用户只读模式保证了模板结构和法律条款不会被手动修改
  • 结合防截图水印,可以在合同预览阶段保护内容安全

四、场景二:AI辅助写作集成

4.1 业务需求

某内容管理平台需要集成 AI 写作能力:

  • 用户在文档中编辑时,可以通过侧边栏调用 AI 功能
  • AI 生成的内容可以插入到当前光标位置
  • 支持 AI 润色:选中文本 → 调用 AI 改写 → 替换原文
  • 支持 AI 续写:在光标位置根据上下文续写内容

4.2 架构设计

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   业务前端    │────→│  AI 服务端    │────→│  大语言模型   │
│  (侧边栏)    │←────│  (API网关)    │←────│  (LLM)       │
└──────┬───────┘     └──────────────┘     └──────────────┘
       │
       │ connector.callCommand()
       ↓
┌──────────────┐
│  OnlyOffice  │
│  编辑器      │
└──────────────┘

4.3 核心实现

获取选中文本,发送给AI处理

// 获取当前选中的文本
function getSelectedText(connector) {
  return new Promise((resolve) => {
    connector.callCommand(
      function () {
        var oDocument = Api.GetDocument();
        var selectedText = oDocument.GetSelectedText();
        return selectedText;
      },
      false, // isNoCalc
      function (result) {
        resolve(result);
      }
    );
  });
}

// AI润色流程
async function aiPolish() {
  const selectedText = await getSelectedText(connector);

  if (!selectedText) {
    alert("请先选中需要润色的文本");
    return;
  }

  // 调用后端AI接口
  const response = await fetch("/api/ai/polish", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text: selectedText })
  });

  const { result } = await response.json();

  // 将AI结果替换选中内容
  connector.callCommand(function () {
    var oDocument = Api.GetDocument();
    // 在当前选区位置插入新文本
    var oParagraph = Api.CreateParagraph();
    oParagraph.AddText(result);
    oDocument.InsertContent([oParagraph], true); // true 表示替换选区
  });
}

在光标位置插入AI生成的内容

async function aiGenerate(prompt) {
  const response = await fetch("/api/ai/generate", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt })
  });

  const { result } = await response.json();

  // 将生成的内容插入光标位置
  connector.callCommand(function () {
    var oParagraph = Api.CreateParagraph();
    var oRun = Api.CreateRun();

    // 设置字体样式与文档保持一致
    oRun.AddText(result);
    oRun.SetFontFamily("SimSun");
    oRun.SetFontSize(24); // 单位是半磅,24 = 12pt

    oParagraph.AddElement(oRun);

    var oDocument = Api.GetDocument();
    oDocument.InsertContent([oParagraph]);
  });
}

4.4 流式输出的处理

如果AI接口支持流式输出(SSE),可以实现逐字显示效果。但需要注意,频繁调用 callCommand 会有性能开销。建议的处理方式:

  • 在侧边栏先完成AI内容的流式展示
  • 用户确认后一次性插入到文档中
  • 或者每积累一定长度(如一个段落)后批量插入
// 推荐:在侧边栏展示完整结果后,一次性插入
function insertAiResult(text) {
  const paragraphs = text.split("\n").filter(p => p.trim());

  connector.callCommand(function () {
    var aContent = [];

    for (var i = 0; i < paragraphs.length; i++) {
      var oParagraph = Api.CreateParagraph();
      oParagraph.AddText(paragraphs[i]);
      aContent.push(oParagraph);
    }

    var oDocument = Api.GetDocument();
    oDocument.InsertContent(aContent);
  });
}

五、场景三:Excel报表自动生成

5.1 业务需求

某数据分析平台需要将统计数据自动填入Excel模板并生成图表:

  • 每月自动生成销售报表
  • 将数据库中的统计数据写入对应的单元格
  • 根据数据自动更新图表
  • 生成后的报表可以供用户在线查看和下载

5.2 写入表格数据

// 销售数据
const salesData = [
  { month: "1月", revenue: 125000, cost: 89000, profit: 36000 },
  { month: "2月", revenue: 138000, cost: 92000, profit: 46000 },
  { month: "3月", revenue: 156000, cost: 98000, profit: 58000 },
  // ...
];

function fillExcelReport(connector, data) {
  // 将数据转为JSON字符串,嵌入到函数中
  const jsonStr = JSON.stringify(data);

  connector.callCommand(function () {
    var data = JSON.parse(jsonStr);
    var oWorksheet = Api.GetActiveSheet();

    // 写入表头
    oWorksheet.GetRange("A1").SetValue("月份");
    oWorksheet.GetRange("B1").SetValue("收入(元)");
    oWorksheet.GetRange("C1").SetValue("成本(元)");
    oWorksheet.GetRange("D1").SetValue("利润(元)");

    // 设置表头样式
    var headerRange = oWorksheet.GetRange("A1:D1");
    headerRange.SetBold(true);
    headerRange.SetFillColor(Api.CreateColorFromRGB(68, 114, 196));
    headerRange.SetFontColor(Api.CreateColorFromRGB(255, 255, 255));

    // 写入数据
    for (var i = 0; i < data.length; i++) {
      var row = i + 2;
      oWorksheet.GetRange("A" + row).SetValue(data[i].month);
      oWorksheet.GetRange("B" + row).SetValue(data[i].revenue);
      oWorksheet.GetRange("C" + row).SetValue(data[i].cost);
      oWorksheet.GetRange("D" + row).SetValue(data[i].profit);
    }

    // 设置数字格式
    var dataRows = data.length;
    oWorksheet.GetRange("B2:D" + (dataRows + 1)).SetNumberFormat("#,##0.00");

    // 自动调整列宽
    oWorksheet.GetRange("A1:D1").SetColumnWidth(15);
  });
}

5.3 自动创建图表

function createChart(connector, dataRowCount) {
  connector.callCommand(function () {
    var oWorksheet = Api.GetActiveSheet();

    // 创建柱状图
    var oChart = oWorksheet.AddChart(
      "'" + oWorksheet.GetName() + "'!$A$1:$D$" + (dataRowCount + 1),
      true,  // 按行
      "bar", // 图表类型
      2,     // 样式
      200 * 36000,   // 宽度(EMU)
      150 * 36000    // 高度(EMU)
    );

    oChart.SetTitle("月度销售报表", 12);
    oChart.SetLegendPos("bottom");

    // 将图表放置在数据下方
    oChart.SetPosition(oWorksheet, dataRowCount + 3, 0, 0, 0);
  });
}

5.4 完整工作流

async function generateMonthlyReport() {
  // 1. 从后端获取数据
  const response = await fetch("/api/reports/monthly-sales");
  const salesData = await response.json();

  // 2. 创建连接器
  const connector = docEditor.createConnector();

  // 3. 填充数据
  fillExcelReport(connector, salesData);

  // 4. 生成图表
  createChart(connector, salesData.length);

  // 5. 通知用户
  showNotification("报表生成完成");
}

六、连接器开发的最佳实践

6.1 数据传递

由于 callCommand 中的函数在编辑器沙箱中执行,不能直接引用外部变量。推荐的数据传递方式:

// 方式一:将数据序列化后拼接到函数体中
function setValueByConnector(connector, cellRef, value) {
  const safeValue = JSON.stringify(value);
  connector.callCommand(
    new Function(`
      var oSheet = Api.GetActiveSheet();
      oSheet.GetRange("${cellRef}").SetValue(${safeValue});
    `)
  );
}

// 方式二:使用 callCommand 的回调获取返回值
connector.callCommand(
  function () {
    return Api.GetDocument().GetStatistics();
  },
  false,
  function (stats) {
    console.log("文档统计:", stats);
  }
);

6.2 错误处理

function safeCallCommand(connector, fn, callback) {
  try {
    connector.callCommand(fn, false, function (result) {
      if (callback) callback(null, result);
    });
  } catch (error) {
    console.error("连接器调用失败:", error);
    if (callback) callback(error, null);
  }
}

6.3 性能优化

  • 批量操作:将多个操作合并到一次 callCommand 调用中,减少通信开销
  • 避免频繁调用:不要在循环中逐次调用 callCommand,应在单次调用中完成所有操作
  • 异步处理callCommand 是异步的,注意操作顺序的控制
// 不推荐:逐行调用
for (let i = 0; i < 1000; i++) {
  connector.callCommand(function () {
    // 写入一行数据
  });
}

// 推荐:一次性写入所有数据
connector.callCommand(function () {
  var oSheet = Api.GetActiveSheet();
  for (var i = 0; i < 1000; i++) {
    oSheet.GetRange("A" + (i + 1)).SetValue("data" + i);
  }
});

6.4 动态权限切换(中国版特有)

中国版自 9.3.0 版本开始支持通过连接器动态修改用户权限,无需重新打开文档即可实时生效。

使用场景

  • 审批流程中,审批人点击"开始审批"后自动切换为只读模式
  • 文档状态变化时,动态调整用户的编辑/复制/打印权限
  • 根据业务规则,在特定条件下限制用户操作

实现示例

// 创建连接器
const connector = docEditor.createConnector();

// 审批人点击"开始审批"按钮时,切换为只读+可评论
function onStartReview() {
  connector.callCommand(function () {
    Api.changePermissions({
      edit: false,
      comment: true,
      copy: true,
      copyOut: false,
      print: false
    });
  });
}

// 审批通过后,进入签署阶段,完全禁止操作
function onApproved() {
  connector.callCommand(function () {
    Api.changePermissions({
      edit: false,
      comment: false,
      copy: false,
      copyOut: false,
      print: false
    });
  });
}

// 审批驳回,退回给起草人编辑
function onRejected() {
  connector.callCommand(function () {
    Api.changePermissions({
      edit: true,
      comment: true,
      copy: true,
      copyOut: true,
      print: true
    });
  });
}

支持的权限字段

字段 说明 类型
comment 是否允许评论 Boolean
copy 是否允许复制 Boolean
copyOut 是否允许复制到外部(中国版特有) Boolean
edit 是否允许编辑 Boolean
print 是否允许打印 Boolean

注意:动态权限切换为高级版功能,目前仅支持 Word/Excel/PPT 的 PC 模式

6.5 与中国版增强功能的配合

连接器可以与中国版的多个增强功能组合使用,构建更强大的业务场景:

组合方式 典型场景 关键配置
连接器 + 用户只读模式 合同制作、公文套打 customization.readOnly: true
连接器 + 动态权限切换 审批流程中的权限流转 Api.changePermissions()
连接器 + 防截图水印 安全环境下的自动文档生成 customization.waterMark
连接器 + 内部剪切板 敏感数据填充后防止用户复制到外部 permissions.copyOut: false
连接器 + 迷你工具栏 简化用户编辑体验 customization.miniToolbar: true

七、与WPS JSSDK的对比

对于有国内办公套件集成经验的开发者,可能更熟悉 WPS 的 JSSDK。以下是两者的关键差异:

对比维度 OnlyOffice 连接器 WPS JSSDK
API丰富度 与插件接口相同,覆盖面广 提供标准化接口,覆盖常用场景
文档操作深度 可操作到段落、Run、样式等细粒度 以高层封装为主
私有化部署 完全支持 需要商业授权
学习成本 需了解 OOXML 模型 接口设计更面向业务
扩展性 插件 + 连接器双通道 SDK标准接口

OnlyOffice 连接器的优势在于更深的文档操作能力和完全的私有化支持,适合需要深度定制的企业级场景。

八、总结

OnlyOffice 中国版的连接器为业务系统与文档编辑器之间架起了一座桥梁。通过 JSAPI,外部系统可以像操作数据库一样操作文档内容——读取、写入、格式化、生成图表,一切都可以通过代码完成。

核心价值:

  • 合同生成:模板 + 数据 = 标准合同,告别手工填写
  • AI写作:大模型生成的内容无缝融入文档编辑流程
  • 报表自动化:数据驱动的文档生成,取代重复的手工操作
  • 流程驱动:文档操作与业务流程深度绑定,实现真正的自动化

连接器让 OnlyOffice 不再只是一个编辑器,而是业务系统中可编程的文档引擎。

相关资源

SDD 实战:用 Claude Code + OpenSpec,把 AI 编程变成“流水线”

一、什么是 OpenSpec?

OpenSpec 指的是一个规范驱动开发SDD(Spec-Driven Development) 的范式,为AI编码提供了“规格说明书”,把AICoding从“凭感觉写代码”提升到“按规格任务执行”的高度,告别“开盲盒”式的AI编程。

一句话定义:

在写任何一行代码之前,先定义一份“AI可执行的规格说明书(Spec)”

它的核心思想是:

  • 人类负责:定义规则(Spec)
  • AI 负责:执行规则(生成代码)

(1)什么是规范驱动开发(SDD)?

规范驱动开发(Spec-Driven Development, SDD)是一种软件开发方法论,其核心理念是:

  • 规范定义行为:系统的行为由规范(Specification)明确定义
  • 代码实现规范:代码是对规范的实现,而非规范的替代
  • 规范驱动变更:所有变更都从规范变更开始
  • 规范即文档:规范既是需求文档,也是设计文档

(2)和传统开发有什么不同?

方式 核心输入 AI行为
Prompt 编程 自然语言
OpenSpec 结构化规范 执行

从“描述需求” → “定义系统行为”

二、为什么需要 OpenSpec?

2.1 传统 AI 编程的核心问题

AI 编程助手(如 Claude / Copilot)存在几个致命缺陷:

  • ❌ 模糊输入 → 不稳定输出
  • ❌ 无法形成“系统级约束”
  • ❌ 无版本追踪(改了啥说不清)
  • ❌ 上下文一长就失控
  • ❌ 遗漏重要功能 & 添加了不必要的功能

2.2 OpenSpec 的解决方案

OpenSpec 通过“规范驱动”解决这些问题:

  • ✅ 明确共识:编码前锁定需求
  • ✅ 结构化管理:所有规范集中管理
  • ✅ 可审查:Spec 可读、可评审
  • ✅ 可执行:AI根据确定的需求生成代码
  • ✅ 可追踪:所有变更都有历史

❗ 其本质就是: AI 不再自由发挥,而是严格执行 Spec

三、快速开始 OpenSpec

3.1 环境准备

Node.js >= 20.19.0

全局安装

npm install -g @fission-ai/openspec@latest

验证是否安装成功:

openspec --version

image.png

3.2 初始化项目

cd openspec-demo
openspec init

image.png

初始化过程中会让你选择 AI 编程工具(推荐 Claude Code)。

image.png 完成后会生成核心目录:

image.png

openspec/
├── specs/        # 当前系统规范(源真相)
├── changes/      # 变更提案
└──── archive/      # 历史归档

四、OpenSpec 核心能力(Skills)

4.1 openspec-propose(发起变更)

  • 核心作用:发起一个变更提案

  • 做什么:

    • openspec/changes/ 下创建一个独立变更目录
    • 引导你编写变更说明(proposal.md) :为什么改、改什么、影响范围
    • 生成待完善的规格文档(spec),先和AI对齐需求,在写代码
  • 场景:

    • 新增功能
    • 重构模块
    • 修复重大问题前的需求对齐

4.2 openspec-explore(分析系统)

  • 核心作用:探索与分析当前规范与变更

  • 做什么:

    • 读取 openspec/specs/ 里的现有规范,帮你理解系统当前行为
    • 分析待处理的变更提案,评估影响范围、依赖关系
    • 辅助你细化方案、拆分任务,避免开发时偏离规范
  • 适用场景:

    • 开发前做技术调研、
    • 理解现有系统、
    • 评估变更风险

4.3 openspec-apply-change(生成代码)

  • 核心作用:将规范变更落地到代码实现

  • 做什么:

    • 读取指定变更提案的规范文档
    • 引导 Claude 按规范生成 / 修改代码,严格对齐 spec
    • 确保代码实现与规范完全一致,避免 “写的和想的不一样”
  • 适用场景:

    • 规范定稿后
    • 正式开发 / 迭代代码阶段

4.4 openspec-archive-change(归档)

  • 核心作用:归档已完成的变更,更新项目规范

  • 做什么:

    • 将已实现的变更规范合并到 openspec/specs/ (项目 “源真相”)
    • 把变更目录移动到 openspec/changes/archive/ 归档
    • 生成交付记录,让项目规范始终保持最新状态
  • 适用场景:代码开发完成、测试通过后,正式纳入项目规范

4.5 整体工作流程

  1. propose → 定义需求,定义规范
  2. explore → 分析影响
  3. apply-change → 按照规范生成代码
  4. archive → 更新规范,归档

这其实就是:把软件开发变成“规范驱动流水线”

五、实战:用 OpenSpec + Claude Code 对TodoList进行优化

这是上一篇文章使用Claude Code实现的TodoList

Claude Code 入门实战:从安装配置到真实项目落地

image.png

本次需求:

  1. 待办事项的列表改为使用Table展示,并且支持批量改变完成状态和删除功能;
  2. 在列表中增加创建时间和更新时间两个字段,展示格式为YYYY-MM-DD hh:mm:ss
  3. 现状:添加相同的待办事项可以添加成功;期望:不允许添加重复的待办事项,并给出存在重复的待办事项提示;
  4. 改为Table展示后再调整下页面的样式,待办事项清单的宽度以及背景颜色;

Step 1:通过 /openspec-propose调用openspec的skills

  • /openspec-propose提交需求后,系统自动在openspec/change目录下创建了本次需求的独立目录enhance-todo-list,这里的目录名称可以理解为就是本次的需求ID
  • 目录自动生成标准化需求文档,支持反复评审打磨,确保需求清晰,边界明确后再进入开发阶段,避免需求存在偏差

image.png

创建proposal.md提案文件

## Why

当前待办事项应用使用List组件展示,功能较为基础,不支持批量操作。同时,缺少对重复事项的校验机制,以及用户无法直观查看待办事项的创建和更新时间。这些限制降低了应用的用户体验和管理效率。

## What Changes

- **UI组件升级**: 将List组件替换为Table组件,支持更丰富的展示和交互
- **批量操作**: 新增批量改变完成状态和批量删除功能
- **时间字段增强**: 添加创建时间(createdAt)和更新时间(updatedAt)字段,格式化为 `YYYY-MM-DD hh:mm:ss`
- **重复校验**: 添加待办事项内容去重机制,防止重复添加

## Capabilities

### New Capabilities
- `batch-todo-operations`: 批量操作待办事项(批量完成/取消完成、批量删除)
- `todo-time-tracking`: 待办事项时间记录和展示(创建时间、更新时间)
- `todo-duplicate-validation`: 待办事项重复性校验

### Modified Capabilities
- `todo-crud`: 基础待办事项增删改查(添加重复校验到创建操作)

## Impact

- **代码变更**:
  - `src/components/TodoList.tsx`: 重构为Table组件,添加批量操作逻辑
  - `src/types/todo.ts`: 添加updatedAt字段
  - 新增依赖: `dayjs` 时间处理库

- **API变更**:
  - addTodo: 添加重复校验逻辑
  - 新增: batchToggleTodos、batchDeleteTodos 方法

- **用户体验**:
  - 提供更高效的批量操作能力
  - 更清晰的时间信息展示
  - 避免重复待办事项的创建

  • openspec/change/enhance-todo-list/specs 这个文件里面的内容可以理解为是本次需求的测试用例文件。
  • design.md & tasks.md是根据需求创建的设计文档和将需求拆解为一个个的Task文档
  • 这里就需要我们去确认这个task.md文档中拆解的task是否合理,是否可以满足我们的需求,在后续apply的时候会去执行文档中所有的task

image.png

Step 2: 自动化生成代码,上述文档确认完成后,执行指令: /openspec-apply-change 需求ID

系统将会自动按照tasks.md中的任务清单,逐个执行任务

image.png

完成需求后页面效果: image.png

查看实现的代码,整个过程中几乎没有“手写业务代码”,而是把精力放在“定义系统行为”上面。

这就是OpenSpec传统 AI 编程最大的不同。 image.png

Step 3: 执行 /openspec-archive-change 需求ID将本次的迭代的需求进行归档操作,方便后续追溯

  • 执行完本次的需求文件夹会被移动到openspec/changes/archive/日期+需求ID目录下

image.png

六、总结

OpenSpec 的意义,不只是一个工具,更像是一种开发范式的转变:

从“人写代码,AI辅助”
到“人定义系统,AI负责实现”

在这种模式下:

  • 代码不再是“源真相”,规范才是
  • AI 不再是“猜需求”,而是“执行规则”
  • 开发过程从“不断试错”,变成“按规范推进”

这背后,其实是软件工程的一次“回归”:

👉 回归到“用明确的约束定义系统行为”

当 AI 编码能力越来越强,真正拉开差距的,不再是“谁写代码更快”,而是:

谁能定义出更清晰、更严谨的系统规范

阿里云:调整标准版、专业版用户的API免费额度并支持按量付费

36氪获悉,阿里云宣布调整标准版、专业版用户的API免费额度并支持按量付费。DataWorks标准版、专业版用户,取消每日调用API的数量限制;DataWorks标准版,调用API的免费额度调整为10万次/月;超出部分采用OpenAPI按量付费的方式;DataWorks专业版,调用API的免费额度调整为50万次/月;超出部分采用OpenAPI按量付费的方式;DataWorks基础版和企业版保持不变;各版本的QPS限制保持不变。从2026年4月14日逐步发布,到2026年4月23日所有region生效。

React 文件处理:上传、拖放区与对象 URL

任何稍有规模的应用最终都要处理文件。个人资料编辑页要传头像。笔记应用要附加图片。CSV 导入器要拖放区。相册要在客户端生成缩略图。而每一个这样的功能都要从零开始重做一遍——因为 React 里的文件处理同时涉及三套浏览器 API(<input type="file">、Drag and Drop API、URL.createObjectURL),再加上 React 本身的 ref 和 effect 机制——大多数开发者每次都从头把它们拼一遍。

本文将带你过一遍每个 React 应用迟早都会遇到的四个文件处理基本能力:一个不需要在 DOM 里渲染隐藏 <input> 的文件选择器、一个能接收拖入文件的拖放区、一个不会泄漏内存的对象 URL 助手,以及一个按需加载第三方库的脚本标签加载器。每一个我们都会先写出手动实现,让你看清底层在做什么,然后再换成 ReactUse 里专门的 Hook。最后我们会把四个 Hook 组合成一个完整的照片上传组件,集挑选、拖放、预览和按需加载图片库于一身。

1. 不用隐藏 input 也能选文件

手动实现

React 中传统的文件选择写法看起来人畜无害,但暗藏不少坑:

import { useRef, useState } from "react";

function ManualFilePicker() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [files, setFiles] = useState<FileList | null>(null);

  return (
    <div>
      <input
        ref={inputRef}
        type="file"
        multiple
        accept="image/*"
        style={{ display: "none" }}
        onChange={(e) => setFiles(e.target.files)}
      />
      <button onClick={() => inputRef.current?.click()}>
        选择图片
      </button>
      {files && <p>已选 {files.length} 个文件</p>}
    </div>
  );
}

它能跑,但只要你想用第二次,缝合的痕迹就藏不住了。隐藏的 <input> 仍然在你的渲染树里,你的样式重置必须考虑它的存在。重置选中状态需要写 inputRef.current.value = ""——这种命令式的副作用,React 的 lint 规则会跳出来警告你。要是你想在异步处理逻辑里 await 用户的选择(比如想在一个处理文件的 async handler 里),你还得自己造一个一次性的 promise。

而且你没法在同一个页面上重复使用同一个组件两次而不让 ref 互相打架。如果用户连续选择同一个文件,第二次 change 事件根本不会触发——这是历代 React 开发者都踩过的著名陷阱。

ReactUse 的方式:useFileDialog

useFileDialog 把整个 input 元素从渲染树里抬出去,交给你一个 [files, open, reset] 的元组:

import { useFileDialog } from "@reactuses/core";

function ImagePicker() {
  const [files, open, reset] = useFileDialog({
    multiple: true,
    accept: "image/*",
  });

  return (
    <div>
      <button onClick={() => open()}>选择图片</button>
      <button onClick={reset} disabled={!files}>
        重置
      </button>
      {files && (
        <ul>
          {Array.from(files).map((file) => (
            <li key={file.name}>
              {file.name} —— {(file.size / 1024).toFixed(1)} KB
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

手动版本忽略的三件小事,但都很重要:

  1. 没有隐藏 DOM。input 在内存里创建,不在你的渲染树里。组件输出就是按钮本身。
  2. 每次调用都能传参。在 open() 上直接传选项,可以覆盖 Hook 级别的默认值。想让同一个选择器既能选文档又能选图片?调用时再传 accept 就行。
  3. 真正的重置reset() 同时清空 React state 和底层 input,所以同一个文件可以再选一次。

open() 函数还会返回一个 promise,resolve 时给你已选的文件。这让异步流程清爽得多:

const handleUpload = async () => {
  const picked = await open();
  if (!picked) return;
  await uploadAll(Array.from(picked));
};

你不再需要把逻辑切分到 onChange 和按钮的点击处理函数之间。选择器就是一个可以 await 的函数。

2. 拖放文件区

手动实现

拖放是那种"教程里看着简单,生产环境里裂得稀碎"的 API。最直白的版本:

function ManualDropZone({ onFiles }: { onFiles: (f: File[]) => void }) {
  const [over, setOver] = useState(false);

  return (
    <div
      onDragOver={(e) => {
        e.preventDefault();
        setOver(true);
      }}
      onDragLeave={() => setOver(false)}
      onDrop={(e) => {
        e.preventDefault();
        setOver(false);
        onFiles(Array.from(e.dataTransfer.files));
      }}
      style={{
        border: over ? "2px solid blue" : "2px dashed gray",
        padding: 40,
      }}
    >
      把文件拖到这里
    </div>
  );
}

这个版本看似没问题,直到用户拖到子元素上时一切都崩了。光标一踏进子元素,浏览器就在父元素上触发 dragleave,尽管从逻辑上看文件还在区域内。你的边框开始闪烁,over state 变成谎言。要正确修复它,你得用计数器跟踪 dragenterdragleave,每次离开就减一,只有当计数器归零时才认定文件"离开"了。还得记得在 dragover 上调 preventDefault——否则 drop 根本不会触发——并且记住 dataTransfer.filesFileList 而不是数组。

大多数生产环境里的拖放区都做错了。闪烁就是破绽。

ReactUse 的方式:useDropZone

useDropZone 替你跳完了这套计数器舞蹈:

import { useRef } from "react";
import { useDropZone } from "@reactuses/core";

function CsvDropZone() {
  const dropRef = useRef<HTMLDivElement>(null);
  const isOver = useDropZone(dropRef, (files) => {
    if (!files) return;
    const csvs = files.filter((f) => f.name.endsWith(".csv"));
    console.log("拖入的 CSV:", csvs);
  });

  return (
    <div
      ref={dropRef}
      style={{
        border: isOver ? "2px solid #3b82f6" : "2px dashed #cbd5e1",
        background: isOver ? "#eff6ff" : "transparent",
        padding: 60,
        borderRadius: 12,
        textAlign: "center",
        transition: "all 120ms ease",
      }}
    >
      <p style={{ margin: 0 }}>
        {isOver ? "松开以上传" : "把 CSV 文件拖到这里"}
      </p>
    </div>
  );
}

注意 API 本质上就是 (target, onDrop) => isOver。就这么简单。Hook 内部处理 dragenter/dragover/dragleave/drop,维护进入/离开计数器,让子元素不会破坏高亮,阻止浏览器默认的"在新标签页打开"行为,最后把一个 boolean 还给你来驱动样式。

回调收到的是 File[] | null——null 代表一次空拖放(没错,某些浏览器在用户拖入非文件内容时确实会触发)。你的处理函数可以一次判断后就干净地退出。

3. 用对象 URL 预览文件

手动实现

拿到 File 之后,你通常想把它展示给用户看。浏览器给了你 URL.createObjectURL(blob),可以把任何 blob 变成一个临时 URL,扔进 <img><video> 就能用。代价是:你创建的每一个 URL 都会占内存,必须记得用完调 URL.revokeObjectURL——否则就泄漏了。在 React 里,"用完"通常意味着"组件卸载或文件变化时",这正是 effect 存在的意义,也正是开发者最容易忘记的事情:

function ManualImagePreview({ file }: { file: File | null }) {
  const [url, setUrl] = useState<string>();

  useEffect(() => {
    if (!file) {
      setUrl(undefined);
      return;
    }
    const next = URL.createObjectURL(file);
    setUrl(next);
    return () => URL.revokeObjectURL(next);
  }, [file]);

  if (!url) return null;
  return <img src={url} alt={file?.name} />;
}

这是对的,但是那种"再不小心改一笔就漏的对"。清理函数和 createObjectURL 调用要永远成对存在。多加一个条件 return 或者忘了一个依赖,就会出现一个只有在长会话里才暴露的 bug。

ReactUse 的方式:useObjectUrl

useObjectUrl 是那段 effect 的单行版:

import { useObjectUrl } from "@reactuses/core";

function ImagePreview({ file }: { file: File }) {
  const url = useObjectUrl(file);
  if (!url) return null;
  return (
    <img
      src={url}
      alt={file.name}
      style={{ maxWidth: 200, borderRadius: 8 }}
    />
  );
}

Hook 接管了生命周期。当 file prop 变化时,它会回收旧 URL 并创建新 URL。组件卸载时,它会回收最后一个。你不可能忘记清理,因为你压根就没写过它。

4. 按需加载第三方脚本

手动实现

有时候你想处理的文件,对应的库太大或太冷门,不值得放进主包。图片裁剪库、PDF 解析器、OCR 引擎、视频转码器——它们都是几十 MB 的体积,对那些从不上传文件的用户来说一文不值。你只想在第一个文件到来之后才付出这个代价。

在 React 里手动加载脚本标签本身就是一道菜谱:

function loadScript(src: string): Promise<void> {
  return new Promise((resolve, reject) => {
    if (document.querySelector(`script[src="${src}"]`)) {
      resolve();
      return;
    }
    const el = document.createElement("script");
    el.src = src;
    el.async = true;
    el.onload = () => resolve();
    el.onerror = () => reject(new Error(`加载失败 ${src}`));
    document.head.appendChild(el);
  });
}

function ManualImageProcessor() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    loadScript("https://cdn.example.com/heavy-image-lib.js")
      .then(() => setReady(true))
      .catch(console.error);
    // 没有清理 —— 一旦加载就保留
  }, []);

  return ready ? <Editor /> : <p>正在加载编辑器...</p>;
}

这覆盖了正常路径,但忽略了乱七八糟的情况:如果两个组件同时请求同一个脚本(竞态条件)怎么办?如果脚本加载失败你想重试怎么办?如果你想在组件消失时主动卸载它怎么办?

ReactUse 的方式:useScriptTag

useScriptTag 给你的就是你本来要写的那些原语,但边界情况都已经处理好:

import { useScriptTag } from "@reactuses/core";

function HeavyImageEditor() {
  const [, status, , unload] = useScriptTag(
    "https://cdn.example.com/image-editor.js",
    () => console.log("编辑器库已就绪"),
    { manual: false, async: true },
  );

  if (status === "loading") return <p>正在下载编辑器...</p>;
  if (status === "error") return <p>编辑器加载失败</p>;
  if (status !== "ready") return null;

  return <ImageEditorComponent onClose={unload} />;
}

四样白送的好处:

  1. 单例行为。同一个脚本 URL 被请求两次,Hook 会去重——没有竞态,没有重复加载。
  2. 状态机idle/loading/ready/error 让你在每一步都能渲染恰当的内容。
  3. 手动控制。设置 manual: true,脚本要等你显式调用返回的 load() 才会加载——非常适合"首次交互时再加载"的模式。
  4. 卸载。调用 unload() 可以把 script 标签从 document 里移除。如果你想在用户关闭编辑器后把那个庞大的库从内存里清掉,这就派上用场了。

全部组合:照片上传组件

现在我们把四个 Hook 组合成一个组件:一个允许用户挑选或拖入图片、即时预览、并在第一次需要时延迟加载一个假想的客户端图片缩放库的照片上传组件。

import { useRef, useState } from "react";
import {
  useFileDialog,
  useDropZone,
  useObjectUrl,
  useScriptTag,
} from "@reactuses/core";

interface QueuedImage {
  file: File;
  id: string;
}

function Thumbnail({ image }: { image: QueuedImage }) {
  const url = useObjectUrl(image.file);
  return (
    <figure
      style={{
        margin: 0,
        padding: 8,
        background: "#f8fafc",
        borderRadius: 8,
        textAlign: "center",
      }}
    >
      {url && (
        <img
          src={url}
          alt={image.file.name}
          style={{
            width: 120,
            height: 120,
            objectFit: "cover",
            borderRadius: 4,
          }}
        />
      )}
      <figcaption
        style={{
          marginTop: 6,
          fontSize: 12,
          maxWidth: 120,
          overflow: "hidden",
          textOverflow: "ellipsis",
          whiteSpace: "nowrap",
        }}
      >
        {image.file.name}
      </figcaption>
    </figure>
  );
}

function PhotoUploadWidget() {
  const [queue, setQueue] = useState<QueuedImage[]>([]);
  const [shouldLoadResizer, setShouldLoadResizer] = useState(false);
  const dropRef = useRef<HTMLDivElement>(null);

  const [, openPicker, resetPicker] = useFileDialog({
    multiple: true,
    accept: "image/*",
  });

  const isOver = useDropZone(dropRef, (files) => {
    if (!files) return;
    addFiles(files);
  });

  const [, resizerStatus] = useScriptTag(
    "https://cdn.example.com/image-resize.js",
    () => console.log("缩放器已就绪"),
    { manual: !shouldLoadResizer },
  );

  const addFiles = (files: File[]) => {
    const newImages = files
      .filter((f) => f.type.startsWith("image/"))
      .map((file) => ({
        file,
        id: `${file.name}-${file.lastModified}-${Math.random()}`,
      }));
    setQueue((prev) => [...prev, ...newImages]);
    if (newImages.length > 0) setShouldLoadResizer(true);
  };

  const handlePick = async () => {
    const picked = await openPicker();
    if (picked) addFiles(Array.from(picked));
  };

  const clearAll = () => {
    setQueue([]);
    resetPicker();
  };

  return (
    <div style={{ maxWidth: 720, fontFamily: "system-ui, sans-serif" }}>
      <div
        ref={dropRef}
        style={{
          border: isOver ? "2px solid #3b82f6" : "2px dashed #cbd5e1",
          background: isOver ? "#eff6ff" : "#ffffff",
          padding: 48,
          borderRadius: 16,
          textAlign: "center",
          transition: "all 120ms ease",
        }}
      >
        <p style={{ marginTop: 0, fontSize: 18 }}>
          {isOver ? "松开即可上传" : "把照片拖到这里"}
        </p>
        <button
          onClick={handlePick}
          style={{
            padding: "8px 16px",
            borderRadius: 8,
            border: "1px solid #3b82f6",
            background: "#3b82f6",
            color: "white",
            cursor: "pointer",
          }}
        >
          或从设备中选择
        </button>
      </div>

      <div
        style={{
          marginTop: 16,
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
        }}
      >
        <span style={{ fontSize: 14, color: "#64748b" }}>
          已排队 {queue.length} 张图片
          {shouldLoadResizer && ` —— 缩放器:${resizerStatus}`}
        </span>
        {queue.length > 0 && (
          <button
            onClick={clearAll}
            style={{
              padding: "6px 12px",
              borderRadius: 6,
              border: "1px solid #cbd5e1",
              background: "white",
              cursor: "pointer",
            }}
          >
            全部清空
          </button>
        )}
      </div>

      {queue.length > 0 && (
        <div
          style={{
            marginTop: 16,
            display: "grid",
            gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
            gap: 12,
          }}
        >
          {queue.map((image) => (
            <Thumbnail key={image.id} image={image} />
          ))}
        </div>
      )}
    </div>
  );
}

四个 Hook,四个职责,互不重叠:

  • useFileDialog 负责"点击挑选"流程,并提供可 await 的 promise
  • useDropZone 处理拖放,并解决子元素引发的边框闪烁
  • useObjectUrl 为每个缩略图生成并回收预览 URL,绑定到组件生命周期
  • useScriptTag 只在第一张图片到来后延迟加载缩放库,并且整个会话只加载一次

组合很自然,因为每个 Hook 只做一件事。Hook 之间不共享 ref,effect 不会级联。你最终发布的组件大概 100 行,大部分是标签和样式,那些棘手的浏览器底层活计被藏在已经经过测试和 SSR 加固的 Hook 里。

安装

npm i @reactuses/core

相关 Hook

  • useFileDialog —— 打开文件选择器,无需在 DOM 中渲染隐藏的 input
  • useDropZone —— 跟踪文件拖入元素的状态,正确处理子元素事件
  • useObjectUrl —— 为 File 和 Blob 创建并自动回收 URL
  • useScriptTag —— 动态加载外部脚本,带状态跟踪和卸载支持
  • useEventListener —— 声明式地附加事件监听器,可用于自定义上传进度事件
  • useSupported —— 响应式地检查浏览器是否支持某个 API

ReactUse 提供了 100+ 个 React Hook。全部探索 →

余承东官宣华为新款大阔折手机

36氪获悉,4月13日,华为常务董事、产品投资评审委员会主任、终端BG董事长余承东官宣华为大阔折手机华为Pura X Max新机将于4月20日正式发布。

隆基机械:目前与张雪机车暂无业务交集

36氪获悉,隆基机械在互动平台表示,公司主营业务为汽车制动盘、制动鼓、轮毂等部件的研发、生产与销售,主要客户集中于乘用车与商用车领域,产品销往全球50多个国家和地区。 张雪机车作为新兴摩托车品牌,其制动系统配套体系与公司现有汽车供应链不属同一体系,目前暂无业务交集。

BaseMetas Fileview 在线文件预览服务部署对接指南

本文面向需要将文件预览能力集成到自有系统的开发人员,覆盖从 Docker 部署、反向代理配置、API 对接到生产环境最佳实践的完整流程。读完本文,你将能够在自己的业务系统中完成 Fileview 的部署与集成。


一、了解 Fileview

1.1 它是什么

BaseMetas Fileview 是一款通用型在线文档预览引擎,支持超过 200 种文件格式的在线预览,覆盖 Office 文档、PDF、OFD、CAD 图纸、3D 模型、代码文件、流程图、思维导图、压缩包、音视频、图片等全格式类型。

它的设计目标是:作为独立的预览服务,通过标准 HTTP 接口集成到任意业务系统中。你的系统只需要把"文件地址"告诉 Fileview,它负责下载、转换、渲染,最终在浏览器中呈现预览结果。

1.2 架构概览

Fileview 内部由两个服务组成,Docker 镜像已将它们打包在一起,对外只暴露一个 HTTP 端口:

┌─────────────────────────────────────────┐
│           Docker 容器 (端口 80)           │
│                                         │
│  ┌─────────────┐    ┌─────────────────┐ │
│  │  预览服务     │───▶│   转换服务       │ │
│  │ (HTTP API)  │    │ (格式转换引擎)    │ │
│  └──────┬──────┘    └────────┬────────┘ │
│         │                    │          │
│     ┌───┴────────────────────┴───┐      │
│     │   RocketMQ + Redis + 存储   │      │
│     └────────────────────────────┘      │
└─────────────────────────────────────────┘
  • 预览服务:对外提供 /preview/api/** 等 HTTP 接口,负责请求编排、文件下载、权限校验、结果缓存、长轮询响应
  • 转换服务:内部服务,通过订阅 MQ 事件被动触发,负责文件格式转换(Office → PDF/HTML、CAD → SVG 等)
  • Redis:统一的状态与缓存中心,存储下载/转换任务状态、缓存预览结果
  • RocketMQ:事件总线,承载预览相关事件,负责下载任务和转换任务的异步投递

作为集成方,你不需要关心内部实现,只需要:部署 → 配置代理 → 调用 API

1.3 集成交互流程

你的业务系统                        Fileview 预览服务
    │                                    │
    │  1. 用户点击文件                      │
    │──────────────────────────────────▶ │
    │  2. 构造预览URL                      │
    │     (包含文件下载地址)                  │
    │  3. 浏览器打开预览URL                  │
    │  ─────────────────────────────────▶│
    │                                    │ 4. 下载文件
    │                                    │ 5. 格式转换
    │                                    │ 6. 返回渲染结果
    │  ◀─────────────────────────────────│
    │  7. 用户看到预览效果                    │

二、环境准备

2.1 硬件要求

规格 最低配置 推荐配置 高并发场景
CPU 2 核 4 核 8 核+
内存 2GB 4GB 8GB+
磁盘 10GB 50GB 100GB+(取决于文件量)

磁盘空间主要用于存储下载的源文件和转换后的结果文件。Fileview 内置临时文件清理机制,会定期清理过期文件。

2.2 软件要求

  • Docker:20.10+(必需)
  • Nginx:生产环境推荐使用反向代理(非必需,但强烈建议)

2.3 支持的 CPU 架构

  • AMD64 (x86_64)
  • ARM64 (aarch64)

三、部署

3.1 拉取镜像

# Docker Hub(首选)
docker pull basemetas/fileview:latest

# 国内镜像加速
docker pull docker.1ms.run/basemetas/fileview:latest
# 或
docker pull dockerproxy.net/basemetas/fileview:latest

3.2 启动服务

最简启动(开发/测试):

docker run -itd \
    --name fileview \
    -p 9000:80 \
    --restart=always \
    basemetas/fileview:latest

容器内部监听 80 端口,映射到宿主机 9000 端口。

挂载数据目录(生产推荐):

docker run -itd \
    --name fileview \
    -p 9000:80 \
    -v /data/fileview/storage:/opt/fileview/data \
    -v /data/fileview/logs:/opt/fileview/logs \
    --restart=always \
    basemetas/fileview:latest

这样可以将文件存储和日志持久化到宿主机,避免容器重建后数据丢失。

3.3 验证部署

浏览器访问 http://<你的服务器IP>:9000/,如果看到 Fileview 欢迎页,说明部署成功。

也可以通过命令行验证:

curl -I http://localhost:9000/preview/welcome
# 应该返回 HTTP 200

四、反向代理配置(生产环境必做)

生产环境中不建议直接暴露 Docker 端口,应通过 Nginx 反向代理统一管理。以下提供两种典型部署模式。

4.1 独立域名部署

Fileview 使用一个独立的域名或子域名:

server {
    listen 443 ssl;
    server_name preview.example.com;

    ssl_certificate     /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://127.0.0.1:9000;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_set_header X-Forwarded-Host $host;
    }
}

此时预览服务的 Base URL 为:https://preview.example.com

4.2 子路径部署(与业务系统同域)

Fileview 部署在业务系统的一个子路径下(如 /fileview/),这种方式可以避免跨域问题:

server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    # 你的业务系统
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Fileview 预览服务
    location /fileview/ {
        proxy_pass http://127.0.0.1:9000/;

        # 关键:告知 Fileview 自己处于子路径下
        proxy_set_header X-Forwarded-Prefix /fileview;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_set_header X-Forwarded-Host $host;
    }
}

X-Forwarded-Prefix 是子路径部署的关键请求头。Fileview 会根据它自动调整生成的预览 URL 前缀,确保返回给浏览器的地址是正确可访问的。

此时预览服务的 Base URL 为:https://app.example.com/fileview

4.3 代理头说明

请求头 作用 是否必需
X-Forwarded-Proto 告知原始协议(http/https) 是(HTTPS 场景)
X-Forwarded-Host 告知原始访问域名 推荐
X-Forwarded-Prefix 告知子路径前缀 子路径部署时必需
X-Forwarded-Port 告知原始访问端口 推荐
REMOTE-HOST 告知原始客户端 IP 推荐
Host 标准 Host 头

Fileview 会按优先级解析这些请求头,动态生成预览 URL 的 Base URL。详细机制参见预览地址生成机制


五、API 集成

Fileview 的集成核心只有一件事:构造正确的预览 URL

5.1 预览 URL 格式

{baseUrl}/preview/view?url={fileUrl}&fileName={fileName}

其中:

  • {baseUrl}:Fileview 服务的访问地址
  • {fileUrl}:文件的网络下载地址(需 URL 编码)
  • {fileName}:文件名(需 URL 编码,用于文件类型判断)

5.2 预览网络文件(最常用)

方式一:query 参数(简单直接)

// 你的系统中,生成文件的下载链接
const fileDownloadUrl = "https://app.example.com/api/files/download?id=12345";
const fileName = "年度报告.docx";

// 构造预览 URL
const previewUrl = `https://app.example.com/fileview/preview/view?url=${encodeURIComponent(fileDownloadUrl)}&fileName=${encodeURIComponent(fileName)}`;

// 打开预览
window.open(previewUrl, "_blank");

参数说明:

参数 类型 必填 说明
url string 文件的网络下载地址,支持 http/https/ftp
fileName string 条件必填 真实文件名(含后缀),用于类型判断。如果 url 中已包含正确后缀(如 .docx),可不传
displayName string 在标题栏显示的文件名,不影响类型判断
watermark string 文字水印内容,支持 \n 换行,建议不超过两行
mode string 显示模式:normal(默认,带菜单栏)或 embed(嵌入模式,无菜单栏)

方式二:data 参数(Base64 编码,隐藏参数)

对于不希望在 URL 中暴露文件地址的场景,可以使用 data 参数将所有参数 Base64 编码后传递:

// 安装 js-base64:npm install js-base64
import { Base64 } from "js-base64";

const opts = {
    url: "https://app.example.com/api/files/download?id=12345",
    fileName: "年度报告.docx",
    displayName: "2025年度报告"
};

// Base64 编码
const base64Data = encodeURIComponent(Base64.encode(JSON.stringify(opts)));

// 构造预览 URL
const previewUrl = `https://app.example.com/fileview/preview/view?data=${base64Data}`;
window.open(previewUrl, "_blank");

CDN 引入方式:

<script src="https://cdn.jsdelivr.net/npm/js-base64@3.7.8/base64.min.js"></script>
<script>
    const opts = {
        url: "https://app.example.com/api/files/download?id=12345",
        fileName: "年度报告.docx"
    };
    const base64Data = encodeURIComponent(Base64.encode(JSON.stringify(opts)));
    const previewUrl = `https://app.example.com/fileview/preview/view?data=${base64Data}`;
    window.open(previewUrl, "_blank");
</script>

5.3 预览本地文件

如果文件已存在于 Fileview 容器可访问的磁盘路径上(比如通过 Docker 卷挂载共享目录),可以使用 path 参数替代 url

const filePath = "/opt/fileview/data/shared/report.docx";
const fileName = "report.docx";

const previewUrl = `https://app.example.com/fileview/preview/view?path=${encodeURIComponent(filePath)}&fileName=${encodeURIComponent(fileName)}`;
window.open(previewUrl, "_blank");

注意:path 是 Fileview 容器内部的文件路径,不是宿主机路径。如果使用 Docker 挂载 -v /host/files:/opt/fileview/data/shared,则对应容器内路径为 /opt/fileview/data/shared/xxx

5.4 两种传参方式对比

特性 query 参数方式 data 参数方式
实现复杂度 中(需 Base64 库)
URL 可读性 文件地址在 URL 中可见 参数被 Base64 编码,不直接可见
参数安全性 一般 有一定隐藏作用
适用场景 内部系统、开发调试 对外系统、安全要求较高场景
后端集成 简单字符串拼接 需 JSON 序列化 + Base64 编码

六、前端集成模式

6.1 新窗口打开(最简单)

function previewFile(fileUrl, fileName) {
    const url = encodeURIComponent(fileUrl);
    const name = encodeURIComponent(fileName);
    const previewUrl = `${FILEVIEW_BASE_URL}/preview/view?url=${url}&fileName=${name}`;
    window.open(previewUrl, "_blank");
}

适合:快速集成、对 UI 无特殊要求的场景。

6.2 iframe 嵌入(推荐)

<iframe
    id="file-preview"
    src=""
    width="100%"
    height="600px"
    frameborder="0"
    allowfullscreen
></iframe>

<script>
function previewInIframe(fileUrl, fileName) {
    const url = encodeURIComponent(fileUrl);
    const name = encodeURIComponent(fileName);
    // 使用 embed 模式去除 Fileview 自带的菜单栏
    const previewUrl = `${FILEVIEW_BASE_URL}/preview/view?url=${url}&fileName=${name}&mode=embed`;
    document.getElementById('file-preview').src = previewUrl;
}
</script>

mode=embed 参数会隐藏 Fileview 的顶部菜单栏,使预览内容更好地嵌入你的页面。

适合:文件详情页、审批流程页面、文档管理系统等需要"内嵌预览"的场景。

6.3 弹窗/抽屉预览

// 以弹窗模式预览(示例使用原生 JS)
function previewInModal(fileUrl, fileName) {
    const url = encodeURIComponent(fileUrl);
    const name = encodeURIComponent(fileName);
    const previewUrl = `${FILEVIEW_BASE_URL}/preview/view?url=${url}&fileName=${name}&mode=embed`;

    // 创建遮罩层
    const overlay = document.createElement('div');
    overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;';
    
    // 创建 iframe 容器
    const container = document.createElement('div');
    container.style.cssText = 'width:90%;height:90%;background:#fff;border-radius:8px;overflow:hidden;position:relative;';
    
    // 关闭按钮
    const closeBtn = document.createElement('button');
    closeBtn.innerText = '关闭';
    closeBtn.style.cssText = 'position:absolute;top:8px;right:12px;z-index:10;padding:4px 12px;cursor:pointer;';
    closeBtn.onclick = () => document.body.removeChild(overlay);
    
    // iframe
    const iframe = document.createElement('iframe');
    iframe.src = previewUrl;
    iframe.style.cssText = 'width:100%;height:100%;border:none;';
    
    container.appendChild(closeBtn);
    container.appendChild(iframe);
    overlay.appendChild(container);
    overlay.onclick = (e) => { if (e.target === overlay) document.body.removeChild(overlay); };
    document.body.appendChild(overlay);
}

适合:列表页"快速预览"、不离开当前页面查看文件的场景。


七、后端集成示例

7.1 Java (Spring Boot)

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

@Service
public class FilePreviewService {

    @Value("${fileview.base-url}")
    private String fileviewBaseUrl;  // 如 https://app.example.com/fileview

    /**
     * 生成文件预览 URL
     * @param fileDownloadUrl 文件下载地址
     * @param fileName 文件名
     * @return 预览 URL
     */
    public String buildPreviewUrl(String fileDownloadUrl, String fileName) {
        String encodedUrl = URLEncoder.encode(fileDownloadUrl, StandardCharsets.UTF_8);
        String encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8);
        return String.format("%s/preview/view?url=%s&fileName=%s",
                fileviewBaseUrl, encodedUrl, encodedName);
    }

    /**
     * 生成带水印的预览 URL
     */
    public String buildPreviewUrlWithWatermark(String fileDownloadUrl, String fileName, String watermark) {
        String encodedUrl = URLEncoder.encode(fileDownloadUrl, StandardCharsets.UTF_8);
        String encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8);
        String encodedWatermark = URLEncoder.encode(watermark, StandardCharsets.UTF_8);
        return String.format("%s/preview/view?url=%s&fileName=%s&watermark=%s",
                fileviewBaseUrl, encodedUrl, encodedName, encodedWatermark);
    }

    /**
     * 生成嵌入模式的预览 URL(无菜单栏)
     */
    public String buildEmbedPreviewUrl(String fileDownloadUrl, String fileName) {
        return buildPreviewUrl(fileDownloadUrl, fileName) + "&mode=embed";
    }
}

在 Controller 中使用:

@RestController
@RequestMapping("/api/files")
public class FileController {

    @Autowired
    private FilePreviewService previewService;

    @GetMapping("/{fileId}/preview-url")
    public Map<String, String> getPreviewUrl(@PathVariable String fileId) {
        // 从你的业务中获取文件信息
        FileInfo file = fileService.getById(fileId);
        String downloadUrl = fileService.generateDownloadUrl(fileId);

        String previewUrl = previewService.buildPreviewUrl(downloadUrl, file.getName());
        return Map.of("previewUrl", previewUrl);
    }
}

7.2 Python (Flask/Django)

from urllib.parse import quote

FILEVIEW_BASE_URL = "https://app.example.com/fileview"

def build_preview_url(file_download_url: str, file_name: str, 
                      watermark: str = None, embed: bool = False) -> str:
    """构造 Fileview 预览 URL"""
    encoded_url = quote(file_download_url, safe='')
    encoded_name = quote(file_name, safe='')
    
    preview_url = f"{FILEVIEW_BASE_URL}/preview/view?url={encoded_url}&fileName={encoded_name}"
    
    if watermark:
        preview_url += f"&watermark={quote(watermark, safe='')}"
    
    if embed:
        preview_url += "&mode=embed"
    
    return preview_url

7.3 Go

package preview

import (
    "fmt"
    "net/url"
)

const FileviewBaseURL = "https://app.example.com/fileview"

func BuildPreviewURL(fileDownloadURL, fileName string) string {
    return fmt.Sprintf("%s/preview/view?url=%s&fileName=%s",
        FileviewBaseURL,
        url.QueryEscape(fileDownloadURL),
        url.QueryEscape(fileName),
    )
}

func BuildEmbedPreviewURL(fileDownloadURL, fileName string) string {
    return BuildPreviewURL(fileDownloadURL, fileName) + "&mode=embed"
}

7.4 PHP

<?php
define('FILEVIEW_BASE_URL', 'https://app.example.com/fileview');

function buildPreviewUrl(string $fileDownloadUrl, string $fileName, 
                         ?string $watermark = null, bool $embed = false): string {
    $params = [
        'url' => $fileDownloadUrl,
        'fileName' => $fileName,
    ];
    
    if ($watermark !== null) {
        $params['watermark'] = $watermark;
    }
    
    if ($embed) {
        $params['mode'] = 'embed';
    }
    
    return FILEVIEW_BASE_URL . '/preview/view?' . http_build_query($params);
}

// 使用
$previewUrl = buildPreviewUrl(
    'https://app.example.com/download/report.docx',
    'report.docx',
    "内部文件\n仅供预览",
    true
);

八、关键功能:文字水印

Fileview 支持在预览时添加文字水印,适用于所有支持水印的文件格式,可用于防止截屏泄露。

// 水印内容支持 \n 换行,建议不超过两行
const watermark = encodeURIComponent("内部文件 仅供预览\n2026-04-12");

const previewUrl = `${FILEVIEW_BASE_URL}/preview/view?url=${fileUrl}&fileName=${fileName}&watermark=${watermark}`;

水印是在预览时动态叠加的,不会修改原始文件。


九、关键功能:嵌入模式

嵌入模式(mode=embed)会隐藏 Fileview 自带的顶部菜单栏(包含文件名、工具按钮等),使预览内容区域全屏展示。

// 普通模式(默认)- 带菜单栏
const normalUrl = `${FILEVIEW_BASE_URL}/preview/view?url=${fileUrl}&fileName=${fileName}`;

// 嵌入模式 - 无菜单栏
const embedUrl = `${FILEVIEW_BASE_URL}/preview/view?url=${fileUrl}&fileName=${fileName}&mode=embed`;

适用于将预览嵌入到你自己的 UI 框架中,由你的系统提供统一的顶栏和操作按钮。


十、安全配置

10.1 可信站点白名单(生产环境必须配置)

Fileview 在预览网络文件时,会先从指定 URL 下载文件。为防止 SSRF 攻击和非授权访问,应配置可信站点白名单,确保 Fileview 只从你的系统域名下载文件。

白名单配置写在 Fileview 的 application.yml 中。对于 Docker 部署,通过环境变量传入:

docker run -itd \
    --name fileview \
    -p 9000:80 \
    -e FILEVIEW_NETWORK_SECURITY_TRUSTED_SITES="app.example.com, *.internal.example.com" \
    --restart=always \
    basemetas/fileview:latest

或在配置文件中:

fileview:
  network:
    security:
      # 仅允许从这些域名下载文件
      trusted-sites: app.example.com, *.internal.example.com
      # 明确禁止的域名(优先级高于白名单)
      untrusted-sites: ""

规则说明:

  • 配置 example.com 会自动匹配其所有子域名
  • 支持通配符:*.example.com
  • 黑名单优先级高于白名单
  • 大小写不敏感
  • 多个规则用逗号分隔

推荐策略:

环境 配置建议
开发/测试 可不配置白名单(允许所有域名)
生产环境 必须配置白名单,仅允许你的系统域名
内网隔离 配置内网域名/IP

10.2 文件下载地址的安全性

你的系统生成的文件下载 URL 应具备基本的安全防护:

  • 临时链接:设置过期时间(如 30 分钟)
  • 签名校验:URL 包含签名参数,防止伪造
  • 权限校验:确保只有授权用户才能生成下载链接
// 推荐:生成带签名和过期时间的临时下载链接
const downloadUrl = generateSignedUrl(fileId, {
    expiresIn: 1800,  // 30 分钟
    signature: computeHmac(fileId + timestamp, secretKey)
});

10.3 加密文件处理

Fileview 支持预览带密码的 Office 文档(docx/xlsx/pptx)和加密压缩包(zip/rar/7z)。

处理流程:

  1. Fileview 检测到文件加密,返回 PASSWORD_REQUIRED 状态
  2. 前端弹出密码输入框
  3. 用户输入密码后,Fileview 验证并解密预览
  4. 密码加密存储在 Redis 中,30 分钟内同一客户端再次访问无需重复输入

十一、文件 URL 的对接要求

这是集成中最关键的一点:你的系统需要提供一个可供 Fileview 服务端下载文件的 URL

11.1 基本要求

  1. 可达性:Fileview 容器能够通过网络访问该 URL
  2. 直接下载:URL 访问后直接返回文件二进制流(而非 HTML 页面)
  3. Content-Type:建议返回正确的 MIME 类型(非必需,Fileview 主要靠 fileName 参数判断类型)

11.2 典型场景

场景 A:文件服务有公开下载接口

https://app.example.com/api/files/download?id=12345&token=xxx

直接将此 URL 作为 url 参数传给 Fileview 即可。

场景 B:文件存储在 OSS/S3

https://your-bucket.oss-cn-hangzhou.aliyuncs.com/files/report.docx?OSSAccessKeyId=xxx&Signature=xxx&Expires=xxx

使用预签名 URL,注意设置合理的过期时间。

场景 C:文件接口需要 Cookie/Token 认证

Fileview 的文件下载是服务端行为(不是浏览器行为),所以不会携带用户的 Cookie。

解决方案:

  • 方案一:生成不需要认证的临时下载链接(推荐)
  • 方案二:将 Token 作为 URL 参数传递(如 ?token=xxx
  • 方案三:使用 Fileview 的本地文件预览,让你的系统先把文件写入共享目录

场景 D:Fileview 和业务系统在同一内网

可以使用内网地址:

http://192.168.1.100:8080/api/files/download?id=12345

但需注意 Docker 网络。如果 Fileview 在 Docker 中,需确保容器能访问宿主机或其他容器的网络:

# 方案一:使用 host.docker.internal 访问宿主机(Docker Desktop 支持)
http://host.docker.internal:8080/api/files/download?id=12345

# 方案二:使用 Docker 网络
docker network create my-net
docker run --network my-net --name fileview ...
docker run --network my-net --name my-app ...
# 此时可用容器名访问:http://my-app:8080/api/files/download?id=12345

十二、Docker Compose 部署(推荐)

对于与业务系统联合部署的场景,推荐使用 Docker Compose:

version: '3.8'

services:
  fileview:
    image: basemetas/fileview:latest
    container_name: fileview
    ports:
      - "9000:80"
    volumes:
      - fileview-data:/opt/fileview/data
      - fileview-logs:/opt/fileview/logs
    restart: always
    networks:
      - app-network

  # 你的业务系统(示例)
  my-app:
    image: your-app:latest
    container_name: my-app
    ports:
      - "8080:8080"
    restart: always
    networks:
      - app-network

  # Nginx 反向代理
  nginx:
    image: nginx:alpine
    container_name: nginx
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./nginx/ssl:/etc/nginx/ssl
    depends_on:
      - fileview
      - my-app
    restart: always
    networks:
      - app-network

volumes:
  fileview-data:
  fileview-logs:

networks:
  app-network:
    driver: bridge

在此配置下,Nginx 的代理配置可以使用容器名(fileviewmy-app)作为上游地址:

# nginx/conf.d/default.conf
server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/nginx/ssl/cert.pem;
    ssl_certificate_key /etc/nginx/ssl/key.pem;

    # 业务系统
    location / {
        proxy_pass http://my-app:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Fileview
    location /fileview/ {
        proxy_pass http://fileview:80/;
        proxy_set_header X-Forwarded-Prefix /fileview;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_set_header X-Forwarded-Host $host;
    }
}

十三、集成自检清单

部署和集成完成后,按以下清单逐项验证:

部署验证

  • 访问 Fileview 欢迎页正常显示
  • 如使用反向代理,通过代理地址访问欢迎页正常
  • 如使用子路径部署,确认 {baseUrl}/preview/welcome 可访问

预览功能验证

  • 使用 url 参数预览一个公开可下载的 PDF 文件
  • 使用 url 参数预览一个 DOCX 文件(需经过格式转换)
  • 使用 url 参数预览一个图片文件
  • 如有本地文件场景,使用 path 参数预览本地文件
  • 验证 mode=embed 嵌入模式生效(无菜单栏)
  • 验证 watermark 水印参数生效

安全验证

  • 配置可信站点白名单后,尝试预览非白名单域名的文件(应被拒绝)
  • 确认文件下载 URL 有适当的鉴权和过期机制

网络验证

  • Fileview 容器能够访问你的文件下载地址
  • 如在 Docker 内,确认容器间网络互通
  • HTTPS 场景下,确认预览 URL 生成为 HTTPS

十四、常见问题排查

问题 1:预览页面白屏或长时间加载

排查步骤:

  1. 查看浏览器 Network 面板,确认预览 URL 请求是否成功(HTTP 200)
  2. 检查 Fileview 容器日志:docker logs fileview
  3. 确认 Fileview 能否下载文件:进入容器测试 curl <文件下载URL>
  4. 对于 Office 文件,首次预览需要格式转换,可能需要几秒到十几秒

问题 2:预览 URL 中的地址不正确

原因: 反向代理未正确传递 X-Forwarded-* 请求头。

解决: 检查 Nginx 配置中是否包含完整的代理头设置,特别是 X-Forwarded-ProtoX-Forwarded-HostX-Forwarded-Prefix

问题 3:文件下载失败

排查步骤:

  1. 确认文件下载 URL 在 Fileview 容器内可访问
  2. 检查白名单配置是否包含文件所在域名
  3. 检查文件下载 URL 是否已过期
  4. 如使用 Docker,检查容器网络配置(DNS 解析、网络连通性)

问题 4:中文文件名乱码

解决: 确保所有参数都经过 encodeURIComponent / URLEncoder.encode 编码。

问题 5:OFD 文件中文显示为方框

原因: 缺少中文字体(思源字体)。

解决: 参考常见问题文档中的字体安装说明。


十五、生产环境最佳实践

15.1 必做事项

  1. 配置反向代理:不直接暴露 Docker 端口
  2. 启用 HTTPS:在 Nginx 层终止 SSL
  3. 配置可信站点白名单:限制文件下载来源
  4. 挂载数据卷:持久化文件存储和日志
  5. 设置容器自动重启--restart=always

15.2 建议事项

  1. 使用子路径部署:避免跨域问题
  2. 生成带签名和过期时间的文件下载 URL
  3. 监控容器资源:关注 CPU、内存、磁盘使用率
  4. 定期查看日志:及时发现转换失败等异常

15.3 性能优化

对于高并发场景,可参考性能调优指南进行以下优化:

  • JVM 堆内存调整(通过 Docker 环境变量)
  • 转换线程池并发度控制
  • Redis 连接池调优
  • 长轮询策略调整

十六、支持的文件格式速查

类别 格式
Office doc, docx, wps, rtf, txt, md 等文档类;xls, xlsx, csv, ods 等表格类;ppt, pptx, odp 等演示类
版式文档 pdf, ofd
图片 jpg, png, gif, bmp, svg, webp, psd, tif, tga, emf, wmf
CAD dwg, dxf
3D 模型 gltf, glb, obj, stl, fbx, ply, dae, wrl, 3ds, 3mf, 3dm
流程图/思维导图 vsd, vsdm, vsdx, vssm, vssx, vstm, vstx, bpmn, drawio, xmind
压缩包 zip, jar, rar, 7z, tar, tar.gz
代码 java, kotlin, scala, python, go, rust, c, cpp, js, ts, vue, php, ruby, shell 等 100+ 语言
音视频 mp4, mp3, webm, wav, aac, ogg, flac, avi, mkv, mov, flv
电子书 epub
文本 html, xml, json, yaml, toml, ini, conf

完整列表参见 支持格式


相关资料

“易加仿生”完成首轮天使轮融资

36氪获悉,近日,专注于ToC端仿生飞行鸟研发与商业化的“易加仿生”完成首轮天使轮融资,本轮融资资金将全部聚焦于消费级产品的研发迭代、规模化量产以及全球ToC市场的渠道布局,涵盖国内电商全平台运营与海外市场拓展。

A股三大指数集体低开,油气股走强

36氪获悉,A股三大指数集体低开,沪指低开0.38%,深成指低开0.53%,创业板指低开0.42%;新股N创达涨近120%;互联网、重型机械、保险板块跌幅居前,国网信通跌超5%,中国动力跌超3%,新华保险跌超1%;林木、油气、煤炭板块领涨,平潭发展涨超6%,和顺石油涨超4%,中煤能源涨超1%。

恒指开盘跌0.71%,恒生科技指数跌0.69%

36氪获悉,恒指开盘跌0.71%,恒生科技指数跌0.69%;石油石化、煤炭板块领涨,山东墨龙涨超6%,中煤能源涨超1%;零售、国防军工板块跌幅居前,西锐跌超2%,京东健康跌超1%。
❌