曾经找到过“Editor.md”,看之心喜,一直想在Angular中集成下这款markdownpad编辑器玩,在网上也只找到一篇通过指令集成的,虽然可以实现,但还是希望能做成组件形式的,之后看到一篇自定义组件的文章,了解到ControlValueAccessor才真正完成这个心愿,现在记录分享与诸公。
ControlValueAccessor
这是自定义表单组件的核心,只有继承这个接口,才有被 Angular的formControl识别的资格。
ControlValueAccessor要处理的就是实现 Model -> View,View -> Model 之间的数据绑定,其具体的作用是:
- 把 form 模型中值映射到视图中
- 当视图发生变化时,通知 form directives 或 form controls
该接口具体如下,已去掉其中的英文注释:
export interface ControlValueAccessor {
writeValue(obj: any): void;
registerOnChange(fn: any): void;
registerOnTouched(fn: any): void;
setDisabledState?(isDisabled: boolean): void;}- writeValue:在初始化的时候将formControl的值传递给原生表单控件(即,将模型中的新值写入视图或 DOM 属性中);
- registerOnChange:用来获取原生表单控件的值更新时通知Angular表单控件更新的函数(即,设置当控件接收到 change 事件后,调用的函数)
- registerOnTouched:用来获取通知用户正在交互的函数(即,设置当控件接收到 touched 事件后,调用的函数)。
- **setDisabledState?(isDisabled: boolean):设置DISABLED状态时做的执行的方法。**即,当控件状态变成 DISABLED 或从 DISABLED 状态变化成 ENABLE 状态时,会调用该函数。该函数会根据参数值,启用或禁用指定的 DOM 元素。

明确来说,那些原生表单控件都有其对应的ControlValueAccessor,比如:
- DefaultValueAccessor - 用于 text 和 textarea 类型的输入控件
- SelectControlValueAccessor - 用于 select 选择控件
- CheckboxControlValueAccessor - 用于 checkbox 复选控件
至于原生表单控件和Angular表单控件能够保持一致的原理,可以看下formControl指令的实现:
// https://github.com/angular/angular/blob/master/packages/forms/src/directives/reactive_directives/form_control_directive.tsexport class FormControlDirective extends NgControl implements OnChanges {... ngOnChanges(changes: SimpleChanges): void { if (this._isControlChanged(changes)) { setUpControl(this.form, this); if (this.control.disabled && this.valueAccessor !.setDisabledState) { this.valueAccessor !.setDisabledState !(true); } this.form.updateValueAndValidity({emitEvent: false}); } if (isPropertyUpdated(changes, this.viewModel)) { _ngModelWarning( 'formControl', FormControlDirective, this, this._ngModelWarningConfig); this.form.setValue(this.model); this.viewModel = this.model; } }...}这里仅列出了部分实现,formControl指令调用了setUpControl函数来实现formControl和ControlValueAccessor之间的交互。
// https://github.com/angular/angular/blob/master/packages/forms/src/directives/shared.ts...
dir.valueAccessor !.writeValue(control.value);
...
function setUpViewChangePipeline(control: FormControl, dir: NgControl): void { dir.valueAccessor !.registerOnChange((newValue: any) => { control._pendingValue = newValue; control._pendingChange = true; control._pendingDirty = true;
if (control.updateOn === 'change') updateControl(control, dir); });}
...
function setUpModelChangePipeline(control: FormControl, dir: NgControl): void { control.registerOnChange((newValue: any, emitModelEvent: boolean) => { // control -> view dir.valueAccessor !.writeValue(newValue);
// control -> ngModel if (emitModelEvent) dir.viewToModelUpdate(newValue); });}
...
function setUpModelChangePipeline(control: FormControl, dir: NgControl): void { control.registerOnChange((newValue: any, emitModelEvent: boolean) => { // control -> view dir.valueAccessor !.writeValue(newValue);
// control -> ngModel if (emitModelEvent) dir.viewToModelUpdate(newValue); });}
...里面确实能看到一些似曾相识的方法,但个人能力有限,无法完全看懂,也就只能到这里了,喜欢深入探究的可以自行探索。
准备工作
经过上面大致了解ControlValueAccessor,在正式开始前还需要最后的准备工作:
- 使用npm或者yarn安装jquery
npm install jquery或者yarn add jquery- 下载Editor.md
- 将需要的css、fonts、images、lib、plugins三个文件夹和editormd.min.js文件放入assets中(其他位置也可,记得配置第3步中对应的angular.json),这里添加的是精简资源,也可以把解压出来的全部放进去,效果如图:

- 配置angular.json
"styles": [ "src/assets/editorMd/css/editormd.min.css", "node_modules/ng-zorro-antd/src/ng-zorro-antd.min.css", "src/styles.css" ], "scripts": [ "node_modules/jquery/dist/jquery.min.js", "src/assets/editorMd/editormd.min.js" ]创建EditorMdComponent
该组件肯定要继承ControlValueAccessor,首先是实现其上面的方法。
writeValue
writeValue(value: string): void { this.value = value; if (this.mdeditor) { this.mdeditor.setMarkdown(this.value); } }registerOnChange
onChange: Function = () => { }; registerOnChange(fn: any): void { this.onChange = fn; }registerOnTouched
本示例中实际未用的该方法,主要是registerOnChange。
onTouched: Function = () => { }; registerOnTouched(fn: any): void { this.onTouched = fn; }setDisabledState
这个也未使用,即便设置也会报mdeditor未知的错误,禁用功能需要使用其他方式解决。
setDisabledState?(isDisabled: boolean): void { if (isDisabled) { this.mdeditor.setDisabled(); } else { this.mdeditor.setEnabled(); } }AfterViewInit
我们需要执行初始化编辑器的操作,故实现了AfterViewInit。
@ViewChild('host') host; // hmtl中添加 #host标识,用于选择组件模板内的节点 ngAfterViewInit(): void { this.init(); }
init() { if (typeof editormd === 'undefined') { console.error('UEditor is missing'); return; } // 检测配置,若无自定义,则用默认配置。 this.editormdConfig = this.editormdConfig != null ? this.editormdConfig : new EditorConfig(); // 监听编辑器加载完成事件处理,由于该编辑器的配置特性,只能提前写好传入。这里是用来处理存在默认值时。 this.editormdConfig.onload = () => { if (this.value) { this.mdeditor.setMarkdown(this.value); } }; // 变化监听处理 this.editormdConfig.onchange = () => { this.updateValue(this.mdeditor.getMarkdown()); }; // 编辑器必须使用<div id></div>的形式,所以只好添加默认id,后期可考虑传入自定义id this.mdeditor = editormd(this.host.nativeElement.id, this.editormdConfig); // 创建编辑器
}
updateValue(value: string) { this.ngZone.run(() => { this.value = value; this.onChange(this.value); // 关键代码 this.onTouched();
this.onValueChange.emit(this.value); this.getHtmlValue.emit({ originalEvent: event, value: this.getHtmlContent() }); }); }OnDestroy
为了安全周期,实现了OnDestroy
ngOnDestroy(): void { this.destroy(); }
destroy() { if (this.mdeditor) { this.mdeditor.removeListener('ready'); this.mdeditor.removeListener('contentChange'); this.mdeditor.editor.remove(); this.mdeditor.destroy(); this.mdeditor = null; }}添加自定义验证功能
注册自定义验证器
其中useExisting用来设置验证函数,可自定义:
const UEDITOR_VALUE_ACCESSOR = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => EditorMdComponent), multi: true};最终代码
EditorMdComponent
import { Component, ViewChild, Input, AfterViewInit, ElementRef, NgZone, Output, EventEmitter, OnDestroy, forwardRef } from '@angular/core';import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';import { EditorConfig } from './editor-config';
declare var editormd: any;
const UEDITOR_VALUE_ACCESSOR = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => EditorMdComponent), multi: true};
@Component({ selector: 'qy-editor-md', templateUrl:` <div id="md" #host > </div> `, styleUrls: ['./editor-md.component.scss'], providers: [UEDITOR_VALUE_ACCESSOR]})export class EditorMdComponent implements AfterViewInit, OnDestroy, ControlValueAccessor {
@Input() editormdConfig: EditorConfig; // 配置选项
// tslint:disable-next-line:no-output-on-prefix @Output() onReady = new EventEmitter(); // tslint:disable-next-line:no-output-on-prefix @Output() onValueChange = new EventEmitter(); // tslint:disable-next-line:no-output-on-prefix @Output() onFocus = new EventEmitter();
@Output() getHtmlValue = new EventEmitter();
@ViewChild('host') host; private mdeditor: any; private value: string; onChange: Function = () => { }; onTouched: Function = () => { }; constructor( private el: ElementRef, private ngZone: NgZone) {
}
ngAfterViewInit(): void { this.init(); }
ngOnDestroy(): void { this.destroy(); } writeValue(value: string): void { this.value = value; console.log('value', value); if (this.mdeditor) { this.mdeditor.setMarkdown(this.value); } } registerOnChange(fn: any): void { this.onChange = fn; } registerOnTouched(fn: any): void { this.onTouched = fn; } setDisabledState?(isDisabled: boolean): void { if (isDisabled) { this.mdeditor.setDisabled(); } else { this.mdeditor.setEnabled(); } }
init() { if (typeof editormd === 'undefined') { console.error('UEditor is missing'); return; } this.editormdConfig = this.editormdConfig != null ? this.editormdConfig : new EditorConfig(); this.editormdConfig.onload = () => { if (this.value) { this.mdeditor.setMarkdown(this.value); } }; this.editormdConfig.onchange = () => { this.updateValue(this.mdeditor.getMarkdown()); }; this.mdeditor = editormd(this.host.nativeElement.id, this.editormdConfig); // 创建编辑器
}
updateValue(value: string) { this.ngZone.run(() => { this.value = value;
this.onChange(this.value); this.onTouched();
this.onValueChange.emit(this.value); this.getHtmlValue.emit({ originalEvent: event, value: this.getHtmlContent() }); }); }
destroy() { if (this.mdeditor) { this.mdeditor.removeListener('ready'); this.mdeditor.removeListener('contentChange'); this.mdeditor.editor.remove(); this.mdeditor.destroy(); this.mdeditor = null; }
}
getMarkContent(): string { return this.mdeditor.getMarkdown(); }
getHtmlContent(): string { console.log('this.mdeditor.getHTML() 1', this.mdeditor.getHTML()); return this.mdeditor.getHTML(); }}EditorConfig
此为默认编辑器配置。
export class EditorConfig { public width = '100%'; public height = '400'; public path = 'assets/editorMd/lib/'; public codeFold: true; public searchReplace = true; public toolbar = true; public emoji = true; public taskList = true; public tex = true; public readOnly = false; public tocm = true; public watch = true; public previewCodeHighlight = true; public saveHTMLToTextarea = true; public markdown = ''; public flowChart = true; public syncScrolling = true; public sequenceDiagram = true; public imageUpload = true; public imageFormats = ['jpg', 'jpeg', 'gif', 'png', 'bmp', 'webp']; public imageUploadURL = '';
constructor() { }
public onload() { } public onchange() { }}最后记得按照正常组件进行引入和声明才可使用哦。
之后就可以在表单组件中可以直接引入了:
<qy-editor-md formControlName="comment" (getHtmlValue)="getHtmlValue($event)" ></qy-editor-md>
评论