Angular 拥有该应用
画布周围的一切:用户是谁,他们被允许做什么,以及结果会走向何方。
- 路由
- 认证
- 应用 UI
- 用户
- 权限
- 账单
- API 层
- 应用状态
将开源的 GrapesJS 可视化编辑器集成到 Angular 应用程序中,构建拖放式页面构建器、CMS 编辑器、着陆页构建器、电子邮件编辑器以及白标视觉编辑体验。
26k+
GitHub 星标
1.4M+
npm 下载量/月
100+
GJS.Market 上的插件
$0
许可费
原版GrapesJS版本——你在添加任何插件之前,从'GrapesJS.init()'中获得的编辑器,带有标准预设。从右侧面板拖动一个块,选中它,然后在样式管理器中编辑其排版和间距。
从grapesjs.com加载第三方页面。点击之前不会有请求。
这些是实时的 GrapesJS 构建,不是 Angular 演示——这正是它的意义所在。GrapesJS 渲染成它完全拥有的容器元素,因此画布、块、样式管理器、响应式预览、资源选择器和撤销历史在 Angular 组件托管该容器后表现相同。
Angular 驱动你的应用。GrapesJS 驱动视觉编辑体验。输出出来的是 HTML、CSS 和你后端存储的项目 JSON 文档。
GrapesJS 与框架无关,可以集成到 Angular 应用中。你通过 Angular 生命周期钩子初始化编辑器,保持编辑器实例在你控制之下,将 GrapesJS 事件连接到 Angular 应用逻辑,并通过自己的后端持久化项目数据。
没有官方的 Angular 封装器。GrapesJS 项目维护的是 React 封装器,没有 Angular 对应的封装,因此 Angular 集成意味着编写组件——大约是下面示例中的三十行——或根据第三方包本身的优劣进行评估。
跳转到工作示例GrapesJS 是视觉编辑引擎。Angular 是应用框架。几乎所有集成问题都是基于期望其中一方完成另一方的工作,因此明确区分两者之间的界限是值得的。
画布周围的一切:用户是谁,他们被允许做什么,以及结果会走向何方。
画布内部的所有内容:可以拖拽、选择、样式和导出的内容。
Angular 是你的应用。GrapesJS 是你的视觉编辑引擎。
一个依赖和一个样式表。GrapesJS 自带 CSS,没有它编辑器会挂载,但渲染为无样式标记——这是首次集成看起来出问题的最常见原因。
1 — 添加依赖关系
npm install grapesjs2 — 注册样式表
// angular.json → projects.<app>.architect.build.options
{
"styles": [
"src/styles.css",
"node_modules/grapesjs/dist/css/grapes.min.css"
]
}在angular.json注册样式表后,会全局应用样式表。如果编辑器存在于一个懒惰加载的路由上,而你又不想在各处都为CSS付费,那就把它导入编辑器组件自己的样式表里。
GrapesJS 需要一个真实元素来附加,并且在初始化时测量该元素。在 ngOnInit 中,模板尚未渲染,因此@ViewChild引用仍未定义,调用会直接失败。ngAfterViewInit 在 Angular 创建组件视图后运行,这也是容器首次出现的时刻。同样的推理也解释了下面样式表中的高度规则:没有解析高度的元素会生成一个编辑器,初始化时不会出错且不显示任何内容。
在你写组件之前
GrapesJS 替换你递交给它的元素的内容。不要在该元素中渲染 Angular 模板内容,也不要绑定到该元素下的任何内容。
主机元素在初始化前需要一个已解析的高度。零高度的容器会挂载一个你看不到的编辑器。
保留一个对编辑器的引用,然后用ngOnDestroyngOnDestroy它。一条路线上两个实时实例会争夺键盘快捷键。
grapesjs 包自带 TypeScript 定义,因此 Editor、Component 和 ProjectData 可以导入而无需单独的 @types 包。
三个文件,没有生成,也没有隐藏。将它们复制到Angular应用程序中,你就有一个可用的可视化编辑器在路由上。本页后面的所有内容——存储、资源、自定义组件、插件——都是对该组件的补充,而非重写。
// editor.component.ts
import {
AfterViewInit,
Component,
ElementRef,
OnDestroy,
ViewChild,
} from '@angular/core';
import grapesjs, { type Editor } from 'grapesjs';
@Component({
selector: 'app-editor',
templateUrl: './editor.component.html',
styleUrl: './editor.component.css',
})
export class EditorComponent implements AfterViewInit, OnDestroy {
// Resolved by the time ngAfterViewInit runs — that is the whole reason
// initialisation lives there and not in ngOnInit.
@ViewChild('editorHost') private host!: ElementRef<HTMLDivElement>;
private editor?: Editor;
ngAfterViewInit(): void {
this.editor = grapesjs.init({
container: this.host.nativeElement,
height: '100%',
width: 'auto',
fromElement: false,
// No persistence yet. Wire your own API before shipping — see Storage.
storageManager: false,
blockManager: {
blocks: [
{
id: 'section',
label: 'Section',
category: 'Layout',
content: '<section class="section"><h2>Headline</h2></section>',
},
{
id: 'text',
label: 'Text',
category: 'Basic',
content: { type: 'text', content: 'Edit me' },
},
{ id: 'image', label: 'Image', category: 'Basic', content: { type: 'image' } },
],
},
});
}
ngOnDestroy(): void {
// GrapesJS owns DOM nodes, document listeners and an undo stack. Angular
// removes the host element; only destroy() releases the rest.
this.editor?.destroy();
this.editor = undefined;
}
}<!-- editor.component.html -->
<!-- GrapesJS takes this element over completely. Do not render Angular
content inside it — the editor owns everything below #editorHost. -->
<div class="editor-shell">
<div #editorHost class="editor-host"></div>
</div>/* editor.component.css */
.editor-shell {
display: block;
block-size: 100dvh;
}
/* GrapesJS measures its container at init. A host with no resolved height
produces an editor that mounts successfully and renders nothing. */
.editor-host {
block-size: 100%;
}这就是整个集成过程。值得重新阅读的是两个生命周期钩子:ngAfterViewInit 中的初始化,因为容器必须先存在;而 ngOnDestroy 中的 destroy(),因为 Angular 移除了主元素后,文档中附带的 GrapesJS 不会释放。
编辑器是大多数包含编辑器的应用中最重的部分。把它放在加载得很懒散的路由后面,可以避免每个从未打开它的用户进入初始捆包。
// app.routes.ts — the editor is the heaviest route in the app. Load it last.
import type { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', loadComponent: () => import('./home/home.component').then((m) => m.HomeComponent) },
{
path: 'pages/:id/edit',
loadComponent: () => import('./editor/editor.component').then((m) => m.EditorComponent),
},
];编辑器的生命周期必须绑定到组件的生命周期。四个部件负责这项工作,每一个缺失时都会以可识别的方式失败。
获取容器元素的引用。通过模板引用变量查询,而不是通过CSS选择器,这样标记重构就无法无声地破坏查找。
组件视图之后运行的第一个钩子存在。在这里初始化。提前初始化会得到一个未定义的元素引用。
把它放在私有字段,或者如果工具栏组件需要触发命令,就把它保存在服务里。之后你做的所有操作都经过这个对象。
呼叫editor.destroy()。它会移除编辑器的DOM,分离文档级监听器,并丢弃撤销堆栈。跳过它会导致泄漏,但能通过导航存活下来。
在编辑器容器尚未存在之前,不要初始化GrapesJS。
这也是大多数“GrapesJS 在 Angular 中无法工作”报告的原因。在 ngOnInit 以及任何构造函数中,视图尚未被创建——容器引用未定义,初始化会抛出。如果容器位于 @if 块或延迟视图内,ngAfterViewInit 也不够:等该分支实际渲染完后再初始化。
GrapesJS 将大量监听器附加到画布上,并在组件被拖拽或调整大小时持续触发监听器。在基于区域的 Angular 应用中,每个监听器都会安排一次变更检测,即使你的组件树中没有任何变化。
// editor.component.ts (excerpt)
import { Component, NgZone, inject, signal } from '@angular/core';
export class EditorComponent {
private readonly zone = inject(NgZone);
readonly selectedTag = signal('');
readonly savedAt = signal<Date | null>(null);
private mount(host: HTMLElement): void {
// The canvas fires pointer events continuously while a block is being
// dragged. Creating the editor outside the zone keeps those from
// scheduling a change-detection pass on every frame.
this.zone.runOutsideAngular(() => {
const editor = grapesjs.init({ container: host, height: '100%' });
// Re-enter only where editor state has to become Angular state.
editor.on('component:selected', (component) => {
this.zone.run(() => this.selectedTag.set(component.get('tagName') ?? ''));
});
editor.on('storage:end:store', () => {
this.zone.run(() => this.savedAt.set(new Date()));
});
this.editor = editor;
});
}
}
// Zoneless applications (provideZonelessChangeDetection) have no zone to
// leave. Set signals from the same editor events and drop NgZone entirely.初始化时离开该区域一次。在接口监听的特定事件处狭窄地重新进入。在 NgZone.run() 中封装每个编辑器回调,会准确返回你离开区域以避免的变更检测流失。
编辑器运行在浏览器中。GrapesJS 基于真实的 DOM 构建画布——它测量元素,创建 iframe 并附加文档监听器——因此初始化必须在浏览器上下文中进行,且不能在服务器渲染时运行。仅显示已发布输出的路由不受影响:即普通的 HTML 和 CSS,在服务器上像其他渲染一样进行渲染。
// editor.component.ts — browser-only initialisation
import {
AfterViewInit,
Component,
ElementRef,
OnDestroy,
PLATFORM_ID,
ViewChild,
inject,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import type { Editor } from 'grapesjs';
export class EditorComponent implements AfterViewInit, OnDestroy {
@ViewChild('editorHost') private host!: ElementRef<HTMLDivElement>;
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private editor?: Editor;
async ngAfterViewInit(): Promise<void> {
if (!this.isBrowser) return;
// A dynamic import keeps the editor out of the server bundle and off the
// critical path of every route that never opens it.
const { default: grapesjs } = await import('grapesjs');
this.editor = grapesjs.init({ container: this.host.nativeElement });
}
ngOnDestroy(): void {
this.editor?.destroy();
}
}最近的 Angular 版本提供了渲染钩子,其回调仅在浏览器平台上运行,绝不会在服务器上运行。在可用的情况下,它表达了同样的保证,无需平台检查,并且自然地与基于信号的视图查询匹配。
// The same guard, expressed with the render hook instead of a platform check.
import { Component, ElementRef, afterNextRender, viewChild } from '@angular/core';
import type { Editor } from 'grapesjs';
@Component({ selector: 'app-editor', template: '<div #editorHost></div>' })
export class EditorComponent {
private readonly host = viewChild.required<ElementRef<HTMLDivElement>>('editorHost');
private editor?: Editor;
constructor() {
// afterNextRender callbacks run on browser platforms only — they never
// execute on the server, so no isPlatformBrowser check is needed.
afterNextRender(async () => {
const { default: grapesjs } = await import('grapesjs');
this.editor = grapesjs.init({ container: this.host().nativeElement });
});
}
}在选择之前,请先确认你的Angular版本支持哪种。这两种模式的存在都是为了执行同一规则——编辑器在浏览器中初始化,其他地方都不做。 angular.dev
页面构建器不仅仅是一个功能。它包含九个子系统,必须相互协调,而GrapesJS发布了全部九个。这是概述;产品层级决策——定价、角色、模板、发布流程——都属于下面链接的专用指南。
可编辑的表面,在自己的 iframe 中渲染,这样页面样式就不会泄漏到你的管理员 UI 里。
每个元素背后的类型模型:它是什么,接受什么,以及它暴露哪些设置。
用户拖曳的调色板。块是映射到组件类型的命名内容。
CSS 编辑面板,可以配置到每个用户允许更改的属性。
图片和媒体选择。它背后的上传端点是你的,所以这里的插件大多是连接到服务提供商。
加载并保存。GrapesJS 定义了契约并调用你的 API——持久化本身就是你的应用程序。
有命名、可调用的操作——撤销、预览、导出,以及你自己注册的工具栏按钮。
画布上的响应断点可以预览和样式化。
编辑器自有的Chrome——按钮、工具栏和区域,如果编辑器置于现有壳体中,这些都可以替换。
九款中有八款随编辑器或插件一起发布。不可避免的是存储——它也必须了解你的用户、权限和数据库。
构建一个完整的Angular页面构建器如果编辑器需要从应用的其他地方访问——比如shell的工具栏、页面列表、预览窗格——那么将初始化转移到可注入服务中,让组件与该服务通信,而不是直接与编辑器沟通。
画布是真正的iframe。这是刻意为之:页面样式不能泄漏到你的管理界面,应用样式也不会渗透到被编辑页面,这正是你想要的隔离,因为两者由不同人设计。
三个伪影,混用会导致实际数据丢失。项目数据是可编辑文档;HTML 和 CSS 是发布结果。它们不可互换,因为导出设计上是有损的。
一个包含组件、样式、页面和资源的JSON文档。这是你存储的内容,以便编辑者可以完全按照原来的状态重新打开页面。把它当作真实的来源。
页面将被提供时的标记。将其与项目数据一起存储,这样发布时无需启动编辑器,即使编辑器不可用,页面也能渲染。
编辑器为该页面生成的样式,根据其管理的规则进行范围限制。用 HTML 提供——这两个输出是分成两个文件的输出。
// Three outputs, three different jobs. Do not use one where you need another.
const projectData = editor.getProjectData(); // editable state → store this
const html = editor.getHtml(); // markup → publish this
const css = editor.getCss(); // styles → publish this
// Reopening a saved project restores components, styles, pages and assets.
// Re-parsing exported HTML does not — it loses everything not in the markup.
editor.loadProjectData(projectData);将导出的 HTML 重新解析回编辑器并不能替代项目数据:它会丢失组件类型、traits、页面结构以及标记中无法表达的其他内容。GrapesJS 是一个编辑器,不是 CMS 或后端——伪影去向以及它们之后发生什么,都是你的应用程序自行决定。
// project-storage.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { Editor, ProjectData } from 'grapesjs';
@Injectable({ providedIn: 'root' })
export class ProjectStorage {
private readonly http = inject(HttpClient);
/** Registers a named storage the editor can then autosave into. */
register(editor: Editor, projectId: string): void {
editor.Storage.add('gjs-api', {
load: () =>
firstValueFrom(this.http.get<ProjectData>(`/api/projects/${projectId}`)),
store: async (data) => {
await firstValueFrom(
this.http.put(`/api/projects/${projectId}`, {
project: data, // what the editor reopens
html: editor.getHtml(), // what the site renders
css: editor.getCss(),
})
);
},
});
}
}// Point the editor at the storage you just registered.
this.storage.register(editor, projectId);
grapesjs.init({
container: host,
storageManager: {
type: 'gjs-api', // the name passed to Storage.add()
autosave: true,
autoload: true,
stepsBeforeSave: 5, // batch edits instead of a request per keystroke
},
});编辑器从不与你的数据库通信。它调用的是你注册的两个方法,分别调用 HttpClient,HttpClient 调用你的 API,使用与应用中其他请求相同的拦截器和认证。
存储可编辑文档
将 getProjectData() 输出作为用户重新打开的记录保持。将导出的 HTML 和 CSS 存储在同一写入中,这样发布就没有第二次往返。
批量写入,不要流式处理
用stepsBeforeSaveautosave可以把一连串编辑变成一个请求。把步数调到你的API上,而不是演示默认。
自动加载,或明确加载
autoload让编辑器在开始时获取。显式加载则给你一个展示自己旋转器的地方,并且将403作为应用程序错误处理,而不是编辑器错误。
历史保存在服务器端
撤销栈存在浏览器,随着标签页的关闭而消失。如果用户需要恢复昨天的页面,那就是你数据库中的版本表,在商店里写的。
保存与发布分开
存档会更新草稿。发布会将特定版本推广到上线路线——一个独立的终点,带有自己的权限检查。
资源管理器是拣选者,不是文件服务。把它指向你的Angular应用中的某个端点,它会把文件发布在那里;之后的所有操作——验证、存储、调整大小、交付——都由你来实现,这也是你能重用已有媒体库的原因。
// Assets go to your endpoint, under your auth — GrapesJS only POSTs files.
grapesjs.init({
container: host,
assetManager: {
upload: `/api/projects/${projectId}/assets`,
uploadName: 'files',
multiUpload: true,
credentials: 'include',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
// Off by default is the safer choice: base64 images end up inside saved
// markup and make every page row grow without limit.
embedAsBase64: false,
// Returning false aborts the upload before the request leaves the browser.
beforeUpload: (files: File[]) =>
files.every((file) => file.size <= MAX_UPLOAD_BYTES && ALLOWED_TYPES.has(file.type)),
},
});
editor.on('asset:upload:start', () => this.uploading.set(true));
editor.on('asset:upload:end', () => this.uploading.set(false));
editor.on('asset:upload:error', (error) => this.uploadError.set(String(error)));这些都不规定特定的后端。S3、Cloudinary、Laravel磁盘或服务器上的文件夹都满足同一个契约:接受文件,返回URL。
注册组件类型是编辑了解产品的方式。自定义类型拥有自己的设置面板、拖拽规则和调色板块——这样内容团队就能使用你领域的概念,而不是原始的 div。
// pricing-table.type.ts — an application-specific building block.
import type { Editor } from 'grapesjs';
export function registerPricingTable(editor: Editor): void {
editor.Components.addType('pricing-table', {
// Lets the editor recognise the type again when a saved page is reparsed.
isComponent: (el) => el.classList?.contains('pricing-table'),
model: {
defaults: {
tagName: 'section',
attributes: { class: 'pricing-table' },
droppable: false,
// Traits become the right-hand settings panel for this component.
traits: [
{ type: 'text', name: 'plan', label: 'Plan name' },
{ type: 'number', name: 'price', label: 'Price' },
{ type: 'checkbox', name: 'featured', label: 'Highlight' },
],
components: `
<h3 data-gjs-type="text">Team</h3>
<p data-gjs-type="text">$29 / month</p>
`,
},
},
});
// A block is how the component reaches the user: one entry in the palette.
editor.Blocks.add('pricing-table', {
label: 'Pricing table',
category: 'Commerce',
content: { type: 'pricing-table' },
});
}典型的应用特定组件
自定义类型有两个原因。isComponent 让编辑在重新解析保存页面时能再次识别你的类型,而 traits 则成为非技术用户实际编辑的设置面板。即使是简单的组件,这两者都值得编写。
所有这些集成都是同一个基础。它们之间的区别在于你注册哪些块、暴露哪些样式,以及输出发布在哪里。
在你的Angular应用中提供可视化页面编辑体验,拥有你自己的模块、品牌限制和发布流程。
构建一个完整的Angular页面构建器让内容团队通过可视化编辑页面,而不是提交工单。编辑器成为他们已有管理应用的一种路径。
看看无头的CMS图案可重复使用的营销版块和版面,由营销团队组装,无需每次文案更换都部署。
着陆页构建模式可视化地构建和管理邮件模板,使用MJML预设生成的标记,能在真实邮件客户端中保存下来。
GrapesJS 用于电子邮件在你的SaaS产品里装一个品牌编辑器,编辑器自己的Chrome替换,这样它就像你的界面,而不是第三方工具。
白标编辑让用户进行视觉编辑,同时你的应用继续控制最终进入数据库的HTML和CSS。
学习如何构建一个 HTML 拖放构建器插件是一个接收编辑器实例的函数,所以这里没有任何针对Angular的特殊内容——任何发布给GrapesJS的内容都可以在Angular组件中工作。这是编辑器挂载后,团队首先处理的四个空白点。
在Angular中构建可视化编辑器是可能的——这是已知的工作量,而非不可能。问题是你想在整个产品生命周期内拥有哪些部分。
| 功能 | 自己构建 | GrapesJS |
|---|---|---|
| 画布 | 自行开发 | 内置 |
| 拖拽 | 自行开发 | 内置 |
| 组件 | 自行开发 | 内置 |
| 区块 | 自行开发 | 内置 |
| 样式 | 自行开发 | 内置 |
| 资源 | 自行开发 | 内置 |
| 存储 | 自行开发 | 可扩展——你提供端点 |
| 插件 | 构建生态系统 | 插件架构 |
| Angular 集成 | 原生于你的应用 | 通过组件集成 |
| 许可证 | 归你所有 | BSD-3-Clause — $0 to use |
“构建”不是批评——这些都是可以构建的。它是对事后维护者的声明。
本表中的事实重新验证了2026-09-02。
Angular 提供应用框架。GrapesJS 提供视觉编辑引擎。
不,值得理解为什么会出现这个问题。GrapesJS 发布了官方的 React 封装器,所以开发者合理期待有类似的 Angular 包装。但没有。这就剩下三种方法,对大多数团队来说,第一种是正确答案。
你自己编写组件——本页上的组件大约有三十行。你拥有生命周期、变更检测策略和升级路径,应用程序和编辑器API之间没有第三方。
社区包存在,可以缩短第一个小时。像评估依赖一样评估一个:查看谁维护它,声明了哪些Angular和GrapesJS版本,以及发布时间。
一旦多个组件需要编辑器——工具栏、页面列表、预览面板——就将初始化迁移到可注入服务中。它与接缝直接集成,并且将编辑器排除在组件树之外。
GrapesJS 组织维护的是 React 封装器,没有 Angular 包。你找到的任何 Angular 库都是第三方的,尽管非常有用——请将其视为依赖选择,而非 GrapesJS 的一部分。
演示是一个组件。产品由四个层组成,编辑器只是其中之一。当编辑器背后有真实用户支持后,大多数团队都会趋向于这种形态。
shell、router,还有一个懒散加载的路由,它拥有编辑器。
初始化时配置一次:哪些组件存在,哪些可以拖拽,哪些可以样式化。
Angular层是编辑器和API之间的一层——也是唯一在客户端做出授权决策的地方。
文物存在的地方,所有许可都被真正执行。
图中除了第二层外,没有任何内容是GrapesJS特有的。这就是重点:编辑器是普通Angular应用中的一个组件,其余部分是你本来就会构建的架构。
看看团队如何将此打包成产品编辑器是运行在你应用中的一个大型软件。这些地方实际上需要花费。
编辑器应在编辑路径打开时创建,而不是在应用开始时。从不编辑的用户绝不应该为此付费。
动态导入将GrapesJS排除在初始捆绑包之外,而初始捆绑包是本列表中最大的杠杆。
创建编辑器时使用runOutsideAngular,这样画布指针事件不会在每一帧触发变更检测。
不要在初始化时给编辑器完整的媒体库。在 API 中分页,按选区要求加载素材。
非常深或非常重复的页面结构会让选择、样式和撤销变慢。更喜欢组件而不是深度嵌套的包装。
使用stepsBeforeSave,这样一次编辑的突发发送变成一个请求,而不是每次按键请求一次。
这些都没有承诺的数字。在编辑前后测量一下自己的编辑路线——每个效果的大小取决于你的块、页面和API。
可视化编辑器会把用户变成HTML的作者,然后你的应用再为他们服务。把编辑器里输出的所有内容都当作用户输入,因为它就是用户输入。
编辑器输出可以包含任意标记,包括如果启用自定义代码块则包含脚本。在发送给除作者以外的任何人之前,先对服务器端进行净化处理。
检查服务器上的类型和规模,而不仅仅是在beforeUpload中。上传内容存储在网页root之外,并从不同的来源发送。
加载和保存路由是普通的 API 端点。它们需要和应用中其他所有东西相同的认证。
在Angular UI中隐藏发布按钮是展示。重要的检查是端点上的。
存储的项目JSON可能会被篡改。加载时验证它,而不是假设那就是你写的那个文档。
在多租户产品中,项目所有者应从服务器会话中推导出——绝不能从请求体中的 id 中获得。
十二点可以区分一个工作演示和真实用户的路径。
编辑器异常报告到与你Angular其他错误相同的地方。
从开源的可视化编辑器开始,将其连接到你的Angular应用,然后用产品所需的插件和集成进行扩展。
用Angular构建应用程序。用GrapesJS构建可视化编辑器。用GJS.Market插件扩展。