GrapesJS + TypeScript

GrapesJS TypeScript:完整集成指南

学习如何将 GrapesJS 与 TypeScript 结合使用:使用带类型的编辑器 API 和事件,创建自定义组件和插件,连接存储,并把 GrapesJS 集成进 React、Next.js、Vue 或 Angular 应用。

包中的类型添加类型编辑器 APIs自定义组件Plugin 开发HTML/CSS 导出React ·Vue ·Angular ·Next.js
简短回答

GrapesJS 支持 TypeScript 吗?

是的——而且这些类型是包裹内发货的。

GrapesJS 0.23.6 在 dist/index.d.ts 发布自己的声明文件,引用包自身的类型字段。安装 grapesjs 后,编辑器和类型集成在一个依赖中,因此从 'grapesjs' 导入 Editor 即可解析,无需额外设置和第二个包。

  • 不要安装@types/grapesjs。那个包根本没有在npm上发布——安装失败时E404会失败,而不是悄悄地给你陈旧的类型。
  • 目前没有 GrapesJS v1.x。当前版本是 0.23.6,授权的 BSD-3-Clause。讲述 1.x 系列的教程描述的版本并不存在。
  • TypeScript 5.0 是底线。声明文件使用了 const 类型参数,因此 4.9 及以下版本无法解析它——并报告失败为“找不到模块'grapesjs”,这会导致大多数用户寻找不需要的类型包。

这些类型涵盖的是编辑器的API表面——编辑器实例、管理器、组件、模块、事件和项目数据。它们并不描述你产品自身的模型,本指南主要是为了区分这两者。

安装后开始
路线

你将学到的内容

十二步,按顺序。每个步骤都链接到涵盖该内容的章节,这样你可以从项目实际所在的位置开始。

  1. 用TypeScript安装GrapesJS一个依赖,没有类型包,以及运行时导入和仅类型导入的区别。
  2. 配置TypeScript编辑工作中重要的四个编译器选项——以及为什么strictNullChecks能真正完成工作。
  3. 为编辑器添加类型grapesjs.init 返回 Editor。保持为 Editor | null 是防止最常见的崩溃的方法。
  4. 组件工作Component、ComponentDefinition,以及大多数教程会弄错的addType签名。
  5. 为块添加类型BlockProperties 在编辑器之外声明,因此块会单独被检查。
  6. 处理事件Editor.on根据活动名称推断其呼应——并且会为你自己的活动保持开放。
  7. 构建插件Plugin<Options>输入两个参数,导出选项界面才是让它变得可行的关键。
  8. 创建自定义组件一个由编辑、API和渲染器重复使用的合成Hero接口。
  9. 为存储和 API 数据添加类型ProjectData 是编辑的 JSON。ProjectRecord 是你的行。它们不是同一种类型。
  10. 用GrapesJS配合React官方包装,以及手动版useEffect——带有StrictMode所需的清理。
  11. 用GrapesJS配合Next.js客户端边界的位置,以及为什么编辑器不能在它上面创建。
  12. 构建制作编辑器你的应用和编辑器之间的一处接缝,避免两者相互渗透。
第一步

1. 用TypeScript安装GrapesJS

有一个套装。类型自带,所以不需要第二次安装,也没有 devDependencies 的 @types 条目。

安装bash
npm install grapesjs

命令不要跑

@types/grapesjs 既不被弃用、取代也不可选——它不在 npm 注册表中。运行它会返回 404,如果你在较早的教程中看到它,那是可靠信号,说明该教程的其余部分也早于捆绑的类型。

bash
# Don't. This package is not published — npm returns E404.
npm install --save-dev @types/grapesjs

运行时导入与仅类型导入的区别

grapesjs 本身是一个值:你调用 grapesjs.init()。Editor、Component 和 Block 是类型:它们只存在于编译过程中。用 import type 标记它们可以明确表示,并确保导入被擦除而不是被拉入你的捆绑包——这在框架代码中最为重要,因为编辑器的零散运行时导入可能会将其拖入服务器渲染。

ts
// Runtime import: the value you actually call.
import grapesjs from 'grapesjs';

// Type-only import: erased at compile time, ships nothing to the bundle.
import type { Editor, Component, Block } from 'grapesjs';

// Editor styles. Without them the canvas renders unstyled.
import 'grapesjs/dist/css/grapes.min.css';
接下来:配置编译器
第二步

2. 配置TypeScript

GrapesJS 不需要特殊配置。你需要正确设置四个选项——其余的 tsconfig 可以保留项目已经用的配置。

tsconfig.jsonjson
{
  "compilerOptions": {
    // GrapesJS's bundled .d.ts uses const type parameters, a TypeScript 5.0
    // feature. On 4.9 and below the file fails to *parse*, and every import
    // from 'grapesjs' reports "Cannot find module".
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",

    // The editor manipulates real DOM nodes: container elements, iframes,
    // drag events. Without the DOM lib none of that type-checks.
    "lib": ["ES2020", "DOM", "DOM.Iterable"],

    // strict is what makes the typings worth having. In particular
    // strictNullChecks is what forces you to handle "editor not created yet",
    // which is the single most common GrapesJS runtime crash.
    "strict": true,

    "skipLibCheck": true,
    "esModuleInterop": true
  }
}
目标与模块
从ES2020以后的任何内容都可以。声明文件使用模板的文字类型和const类型的参数,所以约束是TypeScript版本,而不是发射目标。
lib 必须包含 DOM
编辑器处理真实元素、i帧和拖拽事件。没有DOM库,grapesjs.init({ container: element })无法进行类型检查,故障看起来更像是GrapesJS的问题,而非配置问题。
严格——具体来说是strictNullChecks
这是它最值得使用的选项。它迫使你处理编辑器尚未存在的窗口:init 前、摧毁后,以及参考的第一次渲染时。这个空白是框架集成中运行时最常见的错误来源。
moduleResolution
bundler 用于 Vite、Next.js 及大多数现代设置;如果通过 Node 自己的算法解析,则使用 node16 或 nodenext。两者都能找到包的类型字段。
第三步

3. 理解GrapesJS类型

这些是你实际会导入的名称。每个名称都是与grapesjs 0.23.6声明文件对照的,而不是从文档网站复制,文档描述JavaScript API,且不总是使用相同的名称。

类型代表常见用途
Editor编辑器实例一切:生命周期、管理器、导出、事件
EditorConfig接口对象传递给初始构建远离呼叫站点的配置
Component画布树中的一个节点读取并更新选定元素
ComponentDefinition接口声明的组件,不是实例组件的子节点;块的内容
ComponentProperties接口组件的模型字段为传给 addType 的默认值添加类型
AddComponentTypeOptions接口参数 addType 实际上取注册自定义组件类型
BlockBlock Manager 中的一个模块Blocks.add 的返回值
BlockProperties接口区块声明在自己的模块中声明块
Trait设置面板里有一个字段自定义特征类型和特征处理器
ProjectData接口编辑器保存的JSON存储加载和存储;你的数据库列
Plugin<T>接口一个带有类型选项的插件函数为你编写或使用的插件添加类型
PluginOptions类型别名插件选项的限制通用插件辅助器和封装器

所有这些都可以按名称导入:import type { Editor, Component, BlockProperties },来自'grapesjs'。只有 grapesjs 本身需要运行时导入。

三个你不能导入的名字

管理器类在文件中声明但从未导出,因此按名称导入时TS2614会失败——这是一个令人困惑的错误,因为当你去查找时,这个类显然存在。改用Editor索引:获取器的返回类型是同一类,且别名在不同版本间稳定。

BlockManager
改用Editor['Blocks']
StorageManager
改用Editor['Storage']
ComponentManager
改用Editor['Components']
步骤4

4. 为 GrapesJS Editor 添加类型

grapesjs.init() 返回一个 Editor。注释变量是可选的——推理已经正确了——但命名类型是让你能跨模块边界传递编辑器而不扩大到任何边界的关键。

src/编辑/createEditor.tsts
import grapesjs from 'grapesjs';
import type { Editor } from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';

const editor: Editor = grapesjs.init({
  container: '#gjs',
  height: '100vh',
  storageManager: false,
});

// Both return values are typed, and they are not the same shape:
// getHtml() always returns a string, getCss() can return undefined.
const html: string = editor.getHtml();
const css: string | undefined = editor.getCss();
  • 自动补全遵循实例编辑器。列出所有管理器,每个管理器也列出自己的方法及其真实签名。
  • getHtml() 返回字符串;getCss() 返回字符串 |未定义。差异是真实的,严格模式需要你处理它。
  • destroy() 是 API 的一部分,不是额外的。框架清理取决于它。

可空引用,也就是值为

在框架里,编辑器在首次渲染时并不存在,卸载之后也不应存在。把持有它的变量标注为 Editor | null,编译器就会在每个调用点追问这两个时刻。标注成 Editor 再用断言绕开,只是把这个问题推到了线上。

ts
import type { Editor } from 'grapesjs';

// Not `let editor: Editor` — before init there is no editor, and the type
// should say so. Every call site is then forced to handle the empty case.
let editor: Editor | null = null;

export function exportHtml(): string {
  if (!editor) throw new Error('Editor is not initialised yet');
  return editor.getHtml(); // narrowed to Editor here
}

export function destroy(): void {
  editor?.destroy();
  editor = null;
}

接触经理

每个管理器都挂在编辑器上——editor.Blocks、editor.Components、editor.Storage、editor.Commands——每个getter都有类型,所以自动补全从实例开始。你不能用名称导入管理器类;而是通过Editor给它们加别名。

ts
import type { Editor } from 'grapesjs';

// These names are NOT exported from 'grapesjs' — importing them by name is a
// compile error. Index into Editor instead and you get the same classes.
type BlockManager = Editor['Blocks'];
type StorageManager = Editor['Storage'];
type ComponentManager = Editor['Components'];

export function countBlocks(blocks: BlockManager): number {
  return blocks.getAll().length;
}
接下来:对编辑的操作做出反应
第五步

5. 在TypeScript中处理GrapesJS事件

editor.on 是事件名称上的通用,因此回调签名是从你传递的字符串中推导出来的。实际上这意味着你几乎不应该注释——推理给了你正确的参数类型,而与其不一致的注释是编译错误,而不是静默的不匹配。

src/编辑器/events.tsts
import type { Editor } from 'grapesjs';

export function wireEditorEvents(editor: Editor): void {
  // No annotation needed. `component` is inferred as Component and
  // `options` carries `action`, which tells add from move from clone.
  editor.on('component:add', (component, options) => {
    console.log(component.get('type'), options.action);
  });

  // A different event, a different payload — the callback signature changes
  // with the event name, so a wrong parameter list is a compile error.
  editor.on('component:selected', (component) => {
    console.log(component.getId());
  });

  editor.on('storage:end:store', () => {
    console.log('Project saved');
  });
}

你将使用的事件家族

Component 活动
component:add、component:remove、component:update、component:selected、component:mount。第一个论元是Component;component:add还获得了一个选项对象,其动作区分了加与移动与克隆。
Editor 生命周期
编辑器准备好后加载,更新项目变更,拆除时销毁。这些是你挂入自己的存档指示器或脏状态标志的地方。
存储事件
storage:start:store、storage:end:store、storage:error。正因为它们会在你自己的存储实现周围触发,所以失败的存档可以在UI中出现,而不是在控制台上。
Block 活动
block:drag:start、block:drag:stop以及Block Manager自带的加减功能。对于分析人们实际会用哪些区块很方便。

推断终止

事件名称是有意为之的开放字符串联合:插件定义自己的通道,通道必须不断编译。代价是核心事件名称中的错别字仍然有效,TypeScript 代码——回调只会退回到一个松散的签名,永远不会触发。当事件处理程序神秘地不做时,检查拼写后再检查 API。

ts
import type { Editor } from 'grapesjs';

export function wireCustomEvents(editor: Editor): void {
  // Your own events are allowed — the event name is a string union that stays
  // open, so plugins can define their own channels.
  editor.on('my-plugin:published', (...args: unknown[]) => {
    console.log(args);
  });

  // Which is also the trade-off: this typo compiles. The callback simply falls
  // back to (...args: any[]) and never fires.
  // editor.on('component:selcted', (component) => { ... });
}
接下来:这些活动所包含的组成部分
第六步

6. 类型安全的 GrapesJS Components

组件类型会在画布中注册新的行为:标签、它的 traits、它接受的子节点、解析回 HTML 时如何识别。这里是大多数自定义编辑器工作的地方,也是必须划定 GrapesJS 类型与你类型界限的地方。

签名要做对

editor.Components.addType(type, options)采用AddComponentTypeOptions——模型、视图、isComponent、扩展——而不是ComponentDefinition。ComponentDefinition描述的是树内声明的节点:组件的子节点,或块的内容。将ComponentDefinition交给addType的教程引用的是较早的形状,因此出现的错误并不明显。

src/编辑/components.tsts
import type { Editor, Component } from 'grapesjs';

// YOUR domain model. GrapesJS knows nothing about it, and that is the point:
// this is the shape your API, your database and your React props agree on.
export interface HeroContent {
  headline: string;
  subheadline?: string;
  ctaLabel: string;
  ctaHref: string;
}

const HERO_DEFAULTS: HeroContent = {
  headline: 'Your headline',
  ctaLabel: 'Get started',
  ctaHref: '#',
};

// addType takes AddComponentTypeOptions — model / view / isComponent — not a
// ComponentDefinition. Tutorials that pass a ComponentDefinition here are
// describing an API that no longer exists.
export function registerHero(editor: Editor): void {
  editor.Components.addType('hero', {
    isComponent: (el) => el.dataset?.gjsType === 'hero',
    model: {
      defaults: {
        tagName: 'section',
        droppable: false,
        attributes: { 'data-gjs-type': 'hero' },
        traits: [
          { type: 'text', name: 'headline', label: 'Headline' },
          { type: 'text', name: 'ctaLabel', label: 'Button label' },
          { type: 'text', name: 'ctaHref', label: 'Button link' },
        ],
        ...HERO_DEFAULTS,
      },
    },
  });
}

// The bridge back to your model. component.get() is intentionally loose —
// this function is where that looseness stops and HeroContent begins.
export function readHero(component: Component): HeroContent {
  return {
    headline: component.get('headline') ?? HERO_DEFAULTS.headline,
    subheadline: component.get('subheadline'),
    ctaLabel: component.get('ctaLabel') ?? HERO_DEFAULTS.ctaLabel,
    ctaHref: component.get('ctaHref') ?? HERO_DEFAULTS.ctaHref,
  };
}
  • 特质是设置面板。每个条目都标注了一个模型字段,这样面板和你的界面就能保持一致。
  • isComponent 是加载时识别保存页面的方式。没有它,重新加载时你的自定义部分会变回普通的 div。
  • droppable 和 draggable 是布尔值或选择器——就是防止别人把 hero 放进按钮里。

两种类型系统,刻意分开

GrapesJS 类型描述编辑器 API。你自己的接口应该描述产品的模型。混合它们感觉效率大约持续一周:然后数据库需要的字段只剩下组件属性,组件内部结构就成了你的模式的一部分。保留像 readHero 这样的函数作为两者唯一的交汇点——所有下游接口都用你的接口,而不是 Component。

接下来:把它放在块状物架上
第七步

7. 为 GrapesJS Blocks 添加类型

块是显示在左侧书架上的内容,也是用户拖动的内容。它不是组件——而是对插入内容的声明。提前区分两者是值得的,因为它们的类型不可互换,错误信息也没有说明。

src/编辑/blocks.tsts
import type { Editor, Block, BlockProperties } from 'grapesjs';

// BlockProperties is exported, so the block can be declared away from the
// editor and checked on its own — label, category, media, content.
const heroBlock: BlockProperties = {
  label: 'Hero',
  category: 'Sections',
  media: '<svg viewBox="0 0 24 24"><rect width="24" height="24" /></svg>',
  // A block's content can be a component definition rather than an HTML
  // string, which is how a block and a custom component type stay in sync.
  content: { type: 'hero' },
};

export function addHeroBlock(editor: Editor): Block {
  // Blocks.add(id, props) returns the created Block.
  return editor.Blocks.add('hero', heroBlock);
}

区块声明的意义

身份证
第一个参数是Blocks.add,不是字段。每个编辑器都是唯一的;重新添加相同ID会替换块。
唱片公司
用户在书架上读取的内容。这里唯一属于你本地文件的字段。
类别
分组书架。字符串,或者默认收缩的对象。
内容
插入的内容是:HTML 字符串,还是组件定义。更倾向于定义——它能将块绑定到组件类型,而不是标记的片段。
媒体
缩略图,作为内联SVG。没有什么能阻止你用<img>,但内嵌SVG会遵循编辑器的主题。
属性
应用到书架项目本身——对测试ID和分析钩子有用,但对插入元素无关。

Blocks.add 返回创建的 Block,方便你捕获并稍后调整书架——比如重新排序、隐藏用户计划中未包含的块,或在运行时更换类别标签。

接下来:把所有内容打包成插件
第八步

8. 用TypeScript构建GrapesJS Plugin

GrapesJS 插件是一个接收编辑器和选项对象的函数。这就是整个合同——这就是为什么插件是你想跨编辑器重复使用、发货给其他团队或销售的自然单位。

一个能经得起成长而存活的形状

每个注册类型一个文件。原因不是整洁:blocks.ts导出的BlockProperties对象类型检查时没有编辑器,这样可以进行单元测试和重复使用,完全不启动编辑器。

my-grapesjs-plugin/ ├── src/ │ ├── index.ts # the Plugin<Options> function, and only that │ ├── types.ts # the exported Options interface │ ├── blocks.ts # BlockProperties, one per block │ ├── components.ts # editor.Components.addType calls │ └── commands.ts # editor.Commands.add calls ├── tsconfig.json ├── package.json # "types": "dist/index.d.ts" └── README.md
SRC/index.tsts
import type { Editor, Plugin } from 'grapesjs';

// Export the options type. A consumer cannot configure your plugin safely if
// the shape of `options` lives only inside your implementation.
export interface SectionsPluginOptions {
  category?: string;
  blockPrefix?: string;
}

// Plugin<T> is (editor: Editor, config: T) => PluginResult. Typing the
// function as Plugin<SectionsPluginOptions> checks both parameters for you.
const sectionsPlugin: Plugin<SectionsPluginOptions> = (editor, options) => {
  // Defaults belong here, not in the type — an optional field plus a
  // destructured default is what makes the call site free to omit them.
  const { category = 'Sections', blockPrefix = 'sec' } = options;

  editor.Blocks.add(`${blockPrefix}-hero`, {
    label: 'Hero',
    category,
    content: { type: 'hero' },
  });

  editor.Commands.add(`${blockPrefix}:reset`, {
    run(ed: Editor) {
      ed.setComponents('');
    },
  });
};

export default sectionsPlugin;

这种类型能为你带来什么

选项
导出界面。无法看到选项形状的消费者必须读取你的源代码来配置插件。
默认设置
类型上的可选字段,默认字段在正文中进行结构化。在类型中放置默认字段则使调用站点所需的所有字段都变为。
编辑器参数
由Plugin<T>为你输入。你注册的所有内容——模块、组件类型、命令——都会与真实的管理器签名进行核对。
注册
Blocks、组件类型、命令和事件处理程序都放在同一个函数里。一个在调用时不注册并等待事件的插件也可以。

登记

传入一个闭包,可以让你的选项在调用处保持类型。另一种做法——把插件写进 plugins、把它的配置写进 pluginsOpts——会把这些配置的类型放宽成一条松散记录,于是拼错的键也能通过编译,却什么都不做。

src/编辑/createEditor.tsts
import grapesjs from 'grapesjs';
import sectionsPlugin, { type SectionsPluginOptions } from './my-grapesjs-plugin';

const options: SectionsPluginOptions = { category: 'Marketing' };

grapesjs.init({
  container: '#gjs',
  // Passing a closure keeps the options typed at the call site. The alternative
  // — plugins: [sectionsPlugin] with pluginsOpts — types options as
  // Record<string, any>, so a misspelled key compiles and silently does nothing.
  plugins: [(editor) => sectionsPlugin(editor, options)],
});
接下来:插件发布的组件
第九步

9. 用TypeScript构建自定义Components

设计系统部分不是单一组件——它是一棵带有产品已有名称形状的小树。仅仅描述一次该形状作为接口,就能防止编辑器、API 和渲染器分离。

一个区,四种类型

Hero ← one component type, one interface ├── Heading ← extends 'text' ├── Description ← extends 'text' └── Button ← extends 'link', traits: label + href
src/编辑/design-system.tsts
import type { Editor, ComponentDefinition } from 'grapesjs';

// The composed shape, described once. Every layer below — the editor default,
// the API payload, the renderer — is checked against this one interface.
export interface HeroContent {
  headline: string;
  description: string;
  ctaLabel: string;
  ctaHref: string;
}

// ComponentDefinition is what goes *inside* a tree: the children of a
// component, or the `content` of a block. It is not what addType takes.
const heroChildren = (content: HeroContent): ComponentDefinition[] => [
  { type: 'text', tagName: 'h1', content: content.headline },
  { type: 'text', tagName: 'p', content: content.description },
  {
    type: 'link',
    content: content.ctaLabel,
    attributes: { href: content.ctaHref },
  },
];

export function registerDesignSystem(
  editor: Editor,
  defaults: HeroContent
): void {
  editor.Components.addType('hero', {
    model: {
      defaults: {
        tagName: 'section',
        droppable: false,
        // Children are declared, not hand-written as an HTML string, so a
        // renamed field is a compile error rather than a silently stale block.
        components: heroChildren(defaults),
        traits: [
          { type: 'text', name: 'headline', label: 'Headline' },
          { type: 'text', name: 'ctaHref', label: 'Button link' },
        ],
      },
    },
  });
}

该给什么加类型,什么不用

内容界面
你的。标题、描述、行动号召——营销人员填写的字段和你的API商店。
组件类型
GrapesJS。曾在addType注册过一次,声明了标签、traits和子节点。
特征
桥梁。每个特征都会在模型上命名一个字段,所以在界面中重命名字段应该会破坏特性列表——而默认配置分散在内,确实会破坏。
子女
ComponentDefinition 对象而不是 HTML 字符串。无论你输入什么,字符串都会被编译;需要检查定义。
属性
数据-gjs-类型的存在,这也是isComponent重新加载时匹配的。

回报不是今天的bug减少——而是六个月后,在HeroContent中添加字段时,会生成一个必须更改的每个地方的列表,而不是在代码库中搜索字符串“headline”。

接下来:把它从数据库里进出
第十步

10. 为 GrapesJS 存储和 API 数据添加类型

存储是两种类型系统最关键的交汇点,因为这是最终进入数据库的边界。错误后期成本较高;正确大约需要二十行。

两个形状,而不是一个

ProjectData 是编辑器自己的 JSON。它的内部结构属于 GrapesJS,版本间会变更,不需要手动迁移。你的 ProjectRecord 是一个行:一个 ID、一个所有者、一个名字、一个版本、时间戳——加上一列里那个不透明的斑点。存储它,加载它,内部保持原样。

SRC/类型/projects.tsts
import type { ProjectData } from 'grapesjs';

// The editor's own JSON. ProjectData is deliberately open — its internal shape
// is GrapesJS's business and changes between versions, so treat it as opaque:
// store it, load it, never reach into it or migrate it by hand.

// YOUR row. This is the type your API returns and your database stores, and it
// is not a GrapesJS type. Keeping the two apart is what lets you add a column,
// change a version scheme or move providers without touching editor code.
export interface ProjectRecord {
  id: string;
  name: string;
  userId: string;
  version: number;
  updatedAt: string;
  projectData: ProjectData;
}
  1. GrapesJS
  2. Storage API
  3. Application
  4. Database

编辑器生成项目数据。你的存储适配器是唯一同时处理两侧的代码。

src/编辑/storage.tsts
import grapesjs from 'grapesjs';
import type { Editor, ProjectData } from 'grapesjs';
import type { ProjectRecord } from './types/projects';

export function createEditor(projectId: string): Editor {
  const editor = grapesjs.init({
    container: '#gjs',
    storageManager: {
      // The id of the storage you register below.
      type: 'remote-api',
      autosave: true,
      stepsBeforeSave: 5,
    },
  });

  editor.Storage.add('remote-api', {
    async load(): Promise<ProjectData> {
      const res = await fetch(`/api/projects/${projectId}`);
      // Throwing here is what makes the editor emit storage:error. Returning
      // an empty object instead loses the reader's work without telling them.
      if (!res.ok) throw new Error(`Load failed: ${res.status}`);
      const record = (await res.json()) as ProjectRecord;
      return record.projectData;
    },

    async store(data: ProjectData): Promise<void> {
      const res = await fetch(`/api/projects/${projectId}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ projectData: data }),
      });
      if (!res.ok) throw new Error(`Save failed: ${res.status}`);
    },
  });

  editor.on('storage:error', (error) => console.error(error));

  return editor;
}

适配器需要处理什么

负载
为当前项目退回ProjectData。加载失败的响应会让编辑器发出storage:error,而不是默默打开空白画布。
商店
按原样发送数据。不要在发送时重新调整数据——无论你删减了什么,编辑员都期望加载时返回。
自动保存
autosave 和 stepsBeforeSave 会批量更改。每一次按键都不是请求,这个数字由你自己调。
版本管理
在你的行上设置一个版本列,服务器端递增。记录是版本,绝不要是编辑器的JSON。
多租户
项目 ID 是适配器闭包里的一个变量,归属关系在服务端校验。编辑器没有用户的概念,也不该有。
接下来:把编辑器放进一个框架里
第11步

11. GrapesJS与React和TypeScript

有一个官方的 React 包装器——@grapesjs/react、MIT 授权,目前为 2.0.0——并且它会发布自己的声明文件。它不会在画布内渲染 React 组件;它会挂载编辑器并交付实例。

app/editor/PageEditor.tsxtsx
'use client';

import { useRef } from 'react';
import grapesjs from 'grapesjs';
import type { Editor, ProjectData } from 'grapesjs';
import GjsEditor from '@grapesjs/react';
import 'grapesjs/dist/css/grapes.min.css';

interface PageEditorProps {
  projectId: string;
  onSave: (projectId: string, data: ProjectData) => void;
}

export default function PageEditor({ projectId, onSave }: PageEditorProps) {
  const editorRef = useRef<Editor | null>(null);

  return (
    <GjsEditor
      // Required. The wrapper does not import grapesjs itself — you pass the
      // module (or a CDN URL), which is what lets you control the version.
      grapesjs={grapesjs}
      options={{ height: '100vh', storageManager: false }}
      onEditor={(editor) => {
        editorRef.current = editor;
      }}
      // projectData is typed as ProjectData, so it lines up with the record
      // type your save endpoint expects.
      onUpdate={(projectData) => onSave(projectId, projectData)}
    />
  );
}

必须使用 grapesjs prop。包装器故意不导入编辑器本身,所以你包里的版本保持 package.json 版本——这样你就可以指向 CDN 构建。

或者不包皮

封装只是方便,不是必需品。带 ref 的 useEffect 在大约十五行内完成同样的工作,即使用 Refper 也值得理解,因为它明确了两条规则:守护 ref,和清理时摧毁。

app/editor/PageEditor.tsxtsx
'use client';

import { useEffect, useRef } from 'react';
import grapesjs from 'grapesjs';
import type { Editor } from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';

export default function PageEditor() {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const editorRef = useRef<Editor | null>(null);

  useEffect(() => {
    // strictNullChecks forces this guard, and it is not ceremony: the ref is
    // null on the first render, before React has attached the div.
    if (!containerRef.current) return;

    const editor = grapesjs.init({
      container: containerRef.current,
      height: '100vh',
      storageManager: false,
    });
    editorRef.current = editor;

    // Without destroy(), React 18's development StrictMode double-mount leaves
    // two editors bound to one container.
    return () => {
      editor.destroy();
      editorRef.current = null;
    };
  }, []);

  return <div ref={containerRef} />;
}
  • 容器引用在第一次渲染时是空的。strictNullChecks 让你说接下来会发生什么。
  • 返回一个调用destroy()的清理文件。React 18的开发版StrictMode会挂载两次,没有它,一个div里会有两个编辑器。
  • 包装器的对等范围是React、^18.0.0 || ^19.0.0。在React17上,你是在手动路径上。
完整的React积分指南请参见GrapesJS + React。 接下来:服务器边界也是同样的问题
第12步

12. GrapesJS与Next.js和TypeScript

整个Next.js的问题就是一个边界。grapesjs.init需要真正的DOM元素,还有文档和窗口;而Server Component则没有这些元素。所以编辑器放在Client Component里,上面的所有内容都可以留在服务器上。

线路走向

'use client' 位于创建编辑器的组件顶部,且不高。其上方页面仍为 Server Component:等待参数,检查会话,加载项目并传递普通道具。这种分割值得注意,因为将 'use client' 往上移动一个文件会悄悄地将数据加载变成客户端代码。

app/editor/[id]/editor-client.tsxtsx
// app/editor/[id]/editor-client.tsx
'use client';

import { useEffect, useRef } from 'react';
import grapesjs from 'grapesjs';
import type { Editor } from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';

export default function EditorClient({ projectId }: { projectId: string }) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const editorRef = useRef<Editor | null>(null);

  // grapesjs.init needs a real element, document and window. Calling it in a
  // module body — or in a Server Component — runs it during the server render,
  // where none of those exist. useEffect only runs in the browser, which is
  // the whole requirement.
  useEffect(() => {
    if (!containerRef.current) return;
    const editor = grapesjs.init({
      container: containerRef.current,
      height: '100vh',
    });
    editorRef.current = editor;
    return () => {
      editor.destroy();
      editorRef.current = null;
    };
  }, [projectId]);

  return <div ref={containerRef} />;
}
应用/编辑器/[id]/page.tsxtsx
// app/editor/[id]/page.tsx  — a Server Component, no 'use client'
import EditorClient from './editor-client';

interface PageProps {
  params: Promise<{ id: string }>;
}

export default async function EditorPage({ params }: PageProps) {
  const { id } = await params;

  // Auth, data loading and permissions stay on the server, fully typed.
  // Only the editor itself crosses into the client.
  return <EditorClient projectId={id} />;
}
useEffect,不是模块本体
在服务器上导入grapesjs是无害的——它调用的是失败的init()。useEffect只在浏览器中运行,这就是全部要求。
动态导入是可选的
next/dynamic 与 ssr: false 是基于捆绑包大小的决定,而非正确性,且 Server Component 中不存在。如果编辑器只是大页面中的一小部分,可以选择它;在仅编辑器的路径上跳过它。
CSS
从客户端组件导入grapesjs/dist/css/grapes.min.css。没有它,画布渲染时是未样式的,看起来是坏掉的,而不是没有样式。
清理工作
与 React:destroy() 在效果返回中相同。路由转换会卸载组件,而超出容器寿命的编辑器会泄露监听器。
完整的Next.js组装,请参见Next.js页面构建指南。 下一篇:Vue与Angular
第13步

13. GrapesJS与Vue和Angular

两者都能正常工作,且都遵循与手动React版本相同的结构:模板参考,元素存在后初始化,拆解时销毁。框架专用机制有自己的指南,这里没有浓缩版。

在这两种情况下,类型都是本页一直使用的:Editor、Component、ProjectData。框架变的是init()被调用的位置,而不是返回的内容。

第14步

14. 生产型 GrapesJS Editor 的 TypeScript 架构

以上所有内容都能放进一个文件里。大约在第三个自定义组件时就不再合适了,决定明年是否愉快的是你在应用和编辑器之间的接缝处。

一个经得起的结构

src/ ├── editor/ # everything that touches the Editor instance │ ├── createEditor.ts # grapesjs.init, one place, returns Editor │ ├── plugins.ts # Plugin<T> registrations │ ├── components.ts # Components.addType calls │ ├── blocks.ts # BlockProperties definitions │ ├── commands.ts # Commands.add calls │ └── storage.ts # Storage.add, ProjectData in and out │ ├── types/ │ ├── editor.ts # aliases over GrapesJS types you use a lot │ ├── components.ts # HeroContent and friends — YOUR model │ ├── projects.ts # ProjectRecord — your database row │ └── api.ts # request/response shapes │ └── app/ # imports from types/, never from editor/ internals

一层,宣告

应用代码不应直接从“grapesjs”导入。给它一个模块,重新导出你产品合法知道的少数编辑器类型,给未导出的管理器做别名,并声明你的UI实际依赖的狭窄界面。然后工具栏按钮接管那个接口,而不是整个Editor——即使意外也无法进入编辑器内部。

SRC/类型/editor.tsts
// src/types/editor.ts — the single seam between your app and the editor.
import type { Editor, ProjectData } from 'grapesjs';

// Re-export what your application is allowed to know about.
export type { Editor, ProjectData };

// Manager classes are not exported by name; alias them here once so no other
// file has to remember that.
export type BlockManager = Editor['Blocks'];
export type StorageManager = Editor['Storage'];

// The surface your UI actually depends on. Application code takes this, not a
// full Editor, so a toolbar button cannot quietly reach into editor internals.
export interface EditorFacade {
  getHtml(): string;
  getCss(): string;
  save(): Promise<void>;
  destroy(): void;
}
层次

层以及依赖指向哪一边

你的应用程序在上面,类型在中间,编辑器在下面。依赖关系指向下方,永远不会回溯。

  1. 应用

    你的。对GrapesJS一无所知。

    • 布线
    • 认证
    • 应用状态
    • 你的UI
  2. 类型

    那条缝隙。两个世界唯一被命名的地方。

    • 领域模型
    • 项目记录
    • API 合同
  3. Editor 层

    你的。唯一能从编辑器导入的代码。

    • createEditor
    • 插件
    • Component 类型
    • 存储适配器
  4. GrapesJS

    编辑。由包裹添加类型。

    • 画布
    • 主教练
    • 活动
把这个功能嵌入别人的产品里?
全貌

15. SaaS 页面 Builder 的 TypeScript 架构

页面构建器产品大多不是页面构建器。GrapesJS 涵盖了这条链中的一个盒子;其余的都是你本来就打算写的应用程序,而输入它们之间的连接正是本指南的目标。

  1. SaaS application
  2. Authentication
  3. GrapesJS editor
  4. Typed components
  5. Typed plugins
  6. Storage API
  7. Database
  8. Publishing

GrapesJS拥有编辑界面。认证、存储、租赁、计费和发布均由你负责。

全貌

谁拥有什么

GrapesJS拥有编辑界面。认证、存储、租赁、计费和发布均由你负责。

Your product

你提供

所有编辑都没有意见的内容。

  • 账户、会话与权限
  • 租户和按套餐限制
  • 数据库及其迁移
  • 发布、域名与托管
  • 计费与使用
GrapesJS

GrapesJS 提供

画布内的一切,类型由这个包提供。

  • 画布与拖放
  • Blocks,样式,图层,traits,资源
  • HTML 和 CSS 输出
  • 通过Storage Manager项目
  • Component 类型、插件和命令

编辑器是你产品的组成部分,而不是替代品。

第16步

16. 常见的GrapesJS TypeScript错误

十一种反复出现的失败,大多数是这对组合特有的,而不是TypeScript整体。每一个都是你可以匹配的症状和修复它的变革。

安装@types/grapesjs

症状

npm install fails和E404,或者教程告诉你添加它,你就以为注册表坏了。

修复

删除它。类型都在grapesjs内部,由包的类型字段引用。没有单独的类型包,当前行也没有单独的类型包。

把编辑器标注成 any

症状

LET Editor: any,通常在设置时用来消除一个错误,从未被删除。

修复

Editor 来自“grapesjs”。根节点上的任意节点会传播到每个管理器、每个事件有效载荷和每次导出调用——你保留了 TypeScript 的编译时成本,但失去了所有的好处。

插件选项没有类型

症状

插件会选择opts:any或Record<string, unknown>;用户猜字段名,错别字也没用。

修复

声明并导出一个选项接口,然后将函数输入为Plugin<YourOptions>。然后检查这两个参数,包括调用站点。

混淆Block和Component

症状

比如通过Block而预期有Component,或者试图样式化一个方块却发现它没有样式。

修复

块是一个描述要插入内容的架子条目。组件是画布中的一个节点。Blocks.add 使用 BlockProperties;Components.addType 使用 AddComponentTypeOptions。

将ComponentDefinition传递给addType

症状

组件类型会注册,但表现得像普通的 div——没有 traits,也没有限制。

修复

addType 取 AddComponentTypeOptions:model、view、isComponent、extend。ComponentDefinition 描述树中的节点——组件的子节点或块内容。

假设每个事件的有效载荷相同

症状

为component:add编写的处理程序被重复用于component:remove,第二个参数未定义。

修复

每个事件的有效载荷不同,类型本身就说明了这一点。让推理给你参数,而不是从其他处理程序中标注。

将数据库模型与编辑器内部耦合

症状

你的ProjectRecord在编辑器的JSON中有列与字段镜像,GrapesJS升级意味着迁移。

修复

把ProjectData当作不透明。有一列保存它;你查询的所有内容——所有者、名称、版本、时间戳——都存在于它旁边,而不是内部。

忽略可空的编辑器引用

症状

在快速导航、热加载或路线首次渲染时,“无法读取空属性”。

修复

Editor | null,并处理空。引用上的非空断言会将问题从终端转移到错误追踪器。

遵循预类型教程

症状

有命名导入无法解析,管理器方法不存在,编辑器版本从未发布。

修复

请将API与你node_modules中的声明文件对照,而不是对照博客文章。没有GrapesJS v1.x;当前版本是0.23.6。

不同软件包的版本混合

症状

包裹的Editor和你的Editor看起来一样,但结构不兼容,错误中提到了两条路径。

修复

树上有一个grapesjs。查查npm ls grapesjs——插件下嵌套副本通常是原因。

过宽的自定义接口

症状

一个界面会重新声明编辑器的一半 API,这样你就可以在每次发布时都与真实类型有偏差地传递。

修复

别名现有的Editor['Blocks'],只声明你自己UI需要的狭窄表象。用你自己的类型重新描述编辑器的API是维护,你不必签约。

这些都不是TypeScript的问题。这些是编辑器模型和你产品模型混淆的地方,类型系统只是让混淆在早期显现。

第17步

17. 排查GrapesJS TypeScript错误

四个错误形状几乎涵盖了所有内容。在每种情况下,有用的做法是查看node_modules中的声明文件,而不是去找任何一个——答案就在里面,任何错误都只是推迟问题。

找不到模块“grapesjs”或其对应的类型声明

你会看到

导入线上出现了TS2307,尽管软件包明显安装了,编辑器运行正常。

检查

先看你的TypeScript版本。低于5.0版本,声明文件无法解析,故障会被报告为缺失模块——这确实是误导性的消息。那么moduleResolution:必须是bundler、node16或nodenext,而不是经典版。添加@types/grapesjs也无济于事;那个包不存在。

模块“grapesjs”没有导出成员“X”

你会看到

TS2614出现在声明文件中,最常见的是StorageManager或BlockManager。

检查

管理器类声明但未导出。使用索引访问别名 — Editor['Storage']、Editor['Blocks']、Editor['Components'] — 该别名解析为同一类。如果名称是其他,则 grep node_modules/grapesjs/dist/index.d.ts;如果没有,则属于较旧版本。

类型为 ... 的参数不可分配给参数

你会看到

事件处理程序、addType调用或块声明,完全匹配教程却无法编译。

检查

声明文件中的签名。事件每个名称携带不同的载荷;addType 采用 AddComponentTypeOptions 而不是 ComponentDefinition。将鼠标悬停在编辑器中的方法——真实的签名就在那儿,而且通常和你复制的文章形状不同。

两种不兼容的Editor类型

你会看到

一个React或Next.js构建,封装者的Editor和你的Editor拒绝合并,消息中会指定两条node_modules路径。

检查

重复安装。npm ls grapesjs 会显示第二个副本,通常由具有窄对等范围的插件拉入。去重或对齐版本;封装器自身的对等范围是 ^0.22.5。

针对框架特定的类型错误,React 和 Next.js 指南比本页更深入。

第18步

18. GrapesJS + TypeScript 兼容性

精确版本,与注册表和已安装声明文件核对。尤其是TypeScript底线是通过对每个版本编译来测量的,而不是从更新日志推断。

已验证意味着什么
grapesjs0.23.6在dist/index.d.ts上自行发货。没有单独的类型套件。
typescript>= 5.0底层。4.9及以下版本无法解析声明文件,并将其报告为缺失模块。
typescript7.0.2当前版本,以及本指南采样编译时使用的版本。5.0以上的所有版本都能正常使用。
@grapesjs/react2.0.0官方的React封装,MIT授权,并附带了自己的类型。
react^18.0.0 || ^19.0.0封装器的 React 对等范围。在 React 17 时,手动用 useEffect 初始化编辑器。
grapesjs (peer)^0.22.5封装器的 grapesjs 对等范围——足够宽,当前核心能满足它。
node>=20.9.0GrapesJS 是一个浏览器库,声明没有引擎字段。你实际达到的底层来自你的框架;Next.js 16.3.4 需要这个。

已验证 2026-09-03 与 registry.npmjs.org 和 node_modules/grapesjs/dist/index.d.ts. 的匹配 本页上的每个代码样本均在发布前以严格模式对这些版本进行编译。

这里没有“所有TypeScript版本都适用”,因为事实并非如此:5.0是一个硬底线,而其下面的故障模式足够复杂,值得准确说明。

第19步

19. 使用兼容TypeScript的插件扩展GrapesJS

一旦你了解了核心的 API,插件就能提供额外功能,而无需你从零构建所有功能。这些是 GJS.Market 当前的列表,按每个插件接触的类型表面部分分组。

本页不会告诉你一件事:这些列表都没有宣传捆绑的TypeScript声明,所以应将类型支持视为未验证,并查看插件自身的README。无论如何,插件收到的编辑器是由核心包输入的——因此你对任何插件的集成代码都会被检查,即使插件本身是纯JavaScript。

第20步

20. 自己组装Plugin还是用现有的?

这条线不在于难度。而是关于这种行为是否针对你的产品——因为这决定了两年后谁必须维护它。

需求自己开发使用插件
针对您业务的具体行为是的——没有别人会建造它
常见编辑器功能是的——已经解决了
对代码的完全控制是的是的,开源插件
努力达到第一个可用版本较低的起始点
谁来维护它你的团队Plugin 作者,加上你的集成
你能改变到什么程度随你喜欢这取决于插件

实际上,大多数编辑器同时具备:每个编辑器需要的部分有少量插件,以及你自己为使产品拥有的组件类型。

继续

继续学习GrapesJS

接下来该去哪里,取决于你是还在学习编辑器、把它接入框架,还是围绕它构建产品。

定制开发

组装生产型GrapesJS Editor?

如果建筑部分是你的项目实际所在,剩下的工作通常是集成功能,而不是编辑器功能。这就是我们的工作。

  • TypeScript 插件
  • 自定义组件类型
  • React 积分
  • Next.js 积分
  • 存储与API集成
  • SaaS 编辑器
  • 白标编辑
  • 自定义编辑器 UI
  • 旧版本的迁移
  • 生产架构评测
咨询GrapesJS专家
问题

常见问题解答

GrapesJS支持TypeScript吗?

是的。GrapesJS 0.23.6 发布一个包含包的声明文件,并从自己的类型字段引用,所以一旦安装包,导入类型即可生效。这些类型涵盖编辑器实例、其管理器、组件、块、traits、事件和项目数据。

GrapesJS 包含 TypeScript 定义吗?

是的——在 grapesjs 软件包内的 dist/index.d.ts 上。你可以直接在 node_modules 中阅读,这是将本页任何 API 问题与你实际安装版本对照的最可靠方式。

我需要@types/grapesjs吗?

不行,你也不能安装:这个包没有在npm上发布,安装失败时会有404。如果教程让你添加,那说明早于捆绑的类型,其他建议也很可能过时。

我该如何用TypeScript安装GrapesJS?

npm install grapesjs。这就是整个安装过程——一个依赖,包括类型。然后从“grapesjs”导入grapesjs作为运行时值,从“grapesjs”导入import type { Editor }作为类型。

我该如何为 GrapesJS 编辑器添加类型?

grapesjs.init() 返回 Editor,所以推理已经是正确的。关键在于保留实例:在引用或类字段中输入为 Editor | null,因为编辑器在挂载前或摧毁后实际上不存在,而 strictNullChecks 让每个调用站点都处理这个。

我该如何为 GrapesJS 组件添加类型?

Component 是画布节点。ComponentDefinition 描述树内的声明节点——即组件的子节点,或块内容。注册新类型使用 editor.Components.addType(type, options),该节点使用 AddComponentTypeOptions:model、view、isComponent 和扩展。

我该如何为 GrapesJS 块添加类型?

BlockProperties 是导出的,因此可以在自己的模块中声明一个块,并在没有编辑器作用域的情况下检查。editor.Blocks.add(id, props)会接收该对象并返回创建的 Block。

我该如何处理带有TypeScript的GrapesJS事件?

editor.on 是事件名称上的通用代码,并从事件中派生回调签名,因此你很少需要注释参数。注意事件名称是开放字符串联合体,插件可以自定义事件——这意味着核心事件名称中的错别字仍然会编译,根本不会触发。

我该如何创建一个TypeScript GrapesJS插件?

插件是一个函数,包含编辑器和选项对象。导出一个选项界面,并输入函数为Plugin<YourOptions>——然后两个参数都被勾选,用户可以在不阅读你的源代码的情况下看到如何配置。

我可以用TypeScript创建自定义的GrapesJS组件吗?

是的,这也是类型最有价值的地方。把内容形状描述成你自己的接口,用 addType 注册组件类型,然后让 traits 在接口上命名字段——这样重新命名字段时,在生产环境中会以编译错误的形式出现,而不是作为空白部分。

我可以和React、TypeScript一起使用吗?

是的。@grapesjs/react 2.0.0 是官方的封装器,采用 MIT 许可,并且有自己的捆绑类型;它要求你把 grapesjs 作为道具通过。它的 React 对等范围是 ^18.0.0 || ^19.0.0。在 React 17 上,或者如果你喜欢无封装,带 ref 和 destroy() 清理的 useEffect 也能完成同样的工作。

我可以和Next.js、TypeScript一起使用吗?

是的。把'use client'放在创建编辑器的组件上,不要更高,然后在useEffect里调用grapesjs.init——它需要一个真实元素、文档和窗口,而服务器渲染时这些都不存在。上面的页面可以保持Server Component,继续在服务器上加载数据。

我可以和Vue、TypeScript一起使用吗?

是的:模板参考,onMounted初始化,onBeforeUnmount销毁。将Editor排除在ref()或reaction()之外——封装它使Vue代理成为管理自身内部的对象。没有官方的Vue封装器。

我可以和Angular、TypeScript一起使用吗?

是的:@ViewChild容器,ngAfterViewInit 初始化,ngOnDestroy 拆除,runOutsideAngular 用于编辑的事件循环不驱动变更检测。没有官方的 Angular 封装;npm 上的包是第三方的。

我可以把GrapesJS连接到TypeScript后端吗?

是的——通过Storage Manager,通过注册一个带有加载和存储功能的存储,调用你的API。保持这两种类型分开:ProjectData是编辑器的JSON,应该不透明存储,而你自己的记录类型则保存ID、所有者、名称、版本和你实际查询的时间戳。

我在哪里可以找到兼容TypeScript的GrapesJS插件?

GJS.Market 目录列出了 100+ GrapesJS 插件。请注意,目前单个列表不会宣传捆绑的类型声明,所以请查看每个插件的 README——但插件接收的编辑器对象无论如何都由核心包生成类型,因此你自己的集成代码仍然会被检查。
开始建造

用TypeScript打造你的GrapesJS Editor

从带有类型化的 GrapesJS API 开始,构建自己的组件和插件,连接应用基础设施,并在产品需要更多功能时扩展编辑器。

学习

开始教程

安装依赖包,为编辑器添加类型,几分钟内就能得到一套可用的类型化配置。

开始教程
延伸

探索插件

存储适配器、组件类型和开发工具,都是为GrapesJS开发的。

探索插件
建造

获取自定义开发

类型插件、框架集成和生产架构,与你共同构建。

获取自定义开发

TypeScript 并没有让 GrapesJS 更安全。它让定制的 GrapesJS 编辑器在超出某个文件容量时变得可维护。