前言
最开始讲过这个问卷系统的设计特点,可以让用户来自己写问卷的题型组件,而且要利用React的框架。
既然这样,那必不可少需要一个优秀的在线代码编辑器。
大家都知道VSCode很好用,VSCode是用TypeScript写的,然后封装成的桌面程序。其实,微软有把VSCode使用的编辑器抽离简化出来,叫做Monaco Editor,可以在浏览器中使用。
Monaco Editor虽说相比VSCode的编辑器简化了很多,但仍然提供了很强大的功能,例如Highlighting(代码高亮)和Intellisense(智能感知)等等。再加上对TypeScript的良好支持,成为了当前项目的首选编辑器。
安装 Monaco Editor
npm install react-monaco-editor
这是社区维护的,Monaco Editor的React组件封装,react-monaco-editor。
设计
创建题目表单属性接口
该问卷题型被问卷创建者添加后,对该题目进行编辑的表单,即创建题目表单。
该表单属性接口定义了该题型被加入问卷对应的题目,该问卷所有的题目,以及存放创建者对题型编辑的设置信息。
同时还定义了一个事件,当创建者编辑题型的设置信息改变时触发。
declare interface ICreateQuestionTypeComponentProps {
question: IQuestionModel,
questions: IQuestionModel[],
content: string, // save settings in [content]
onContentChanged: (content: string) => void,
}
展示题目表单属性接口
该问卷题型在已发布的问卷中,被问卷作答者看到和填写的表单,即展示题目表单。
该表单属性接口定义了该题型被加入问卷对应的题目,该问卷所有的题目,该问卷当前用户作答的全部答案,创建者的题型设置,以及存放当前题目作答者的答案。
同时还定义了一个事件,当作答者确认提交本题目时触发。
declare interface IShowQuestionTypeComponentProps {
question: IQuestionModel,
questions: IQuestionModel[],
answers: IAnswerModel[],
createContent: string, // [ICreateQuestionTypeComponentProps.content] settings of the component
content: string, // save answer in [content]
onSubmit: (content: string, nextQuestionIndex?: number) => void,
}
题型样例:填空题
约定:题型的类名均为ExampleQuestionType
。
创建题目表单代码
export interface IExampleQuestionTypeState {
text: string,
}
export class ExampleQuestionType extends React.Component<ICreateQuestionTypeComponentProps, IExampleQuestionTypeState> {
constructor(props: ICreateQuestionTypeComponentProps) {
super(props);
this._onValueChanged = this._onValueChanged.bind(this);
this.state = {
text: this.props.content,
}
}
private _onValueChanged(event: any) {
const content = event.target.value;
this.setState({
text: content,
});
this.props.onContentChanged(content);
}
public render() {
const text = this.state.text;
return (
<div>
<label>Please input something to show</label>
<input type='text' value={text} onChange={this._onValueChanged} />
</div>
);
}
}
展示题目表单代码
约定:getReportTitle
和getReportRow
分别用于获取该题型在问卷报告中的标题(表头),以及根据作答者的答案内容转换后的报告中的一行数据。
export interface IExampleQuestionTypeState {
text: string,
}
export class ExampleQuestionType extends React.Component<IShowQuestionTypeComponentProps, IExampleQuestionTypeState> {
constructor(props: IShowQuestionTypeComponentProps) {
super(props);
this._onValueChanged = this._onValueChanged.bind(this);
this._onSubmit = this._onSubmit.bind(this);
this.state = {
text: this.props.content,
};
}
static getReportTitle() {
return ['题型', '答案'];
}
static getReportRow(content: string) {
return ['填空题', content];
}
private _onValueChanged(event: any) {
const content = event.target.value;
this.setState({
text: content,
});
}
private _onSubmit(event: any) {
const content = this.state.text;
this.props.onSubmit(content);
}
componentWillReceiveProps(nextProps: IShowQuestionTypeComponentProps) {
const content = nextProps.content;
this.setState({
text: content,
});
}
public render() {
const text = this.state.text;
return (
<div>
<label>{this.props.createContent}</label>
<input type='text' value={text} onChange={this._onValueChanged} />
<button onClick={this._onSubmit}>Submit</button>
</div>
);
}
}
调用 Monaco 编辑器
首先导入包不用多说:
import MonacoEditor from 'react-monaco-editor';
然后在render
函数中,加入<MonacoEditor>
组件:
const codeCreate = this.state.model.createFormTSX; // 创建题目表单代码
const isOnlyView = this.props.isOnlyView; // 是否只读
// Monaco Editor的JS库源文件(最新版貌似可以通过安装WebPack Plugin来导入,无需指定源文件目录)
const requireConfig = {
url: 'dist/vs/loader.js',
paths: {
'vs': 'dist/vs',
}
};
// ...
<MonacoEditor
width="100%"
height="600"
language="typescript"
theme="vs-dark"
value={codeCreate}
options={{ readOnly: isOnlyView }}
requireConfig={requireConfig}
editorDidMount={ (editor, monaco) =>
{
this._initEditor();
editor.setModel(this._createFormEditorModel);
}
}
onChange={this._onCreateFormChanged}
/>
初始化 MonacoEditor
可以看到,上面调用<MonacoEditor>
组件中,调用了this._initEditor
,并将this._createFormEditorModel
设置到编辑器的模型(即内容和内容的相关设定)中。
private _initEditor() {
const model = this.state.model;
// 设置Monaco编辑器TypeScript的编译特性
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
declaration: false, // 不是定义文件
target: monaco.languages.typescript.ScriptTarget.ES5, // 编译目标位ES5
allowNonTsExtensions: true, // 允许非TS扩展名
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, // 模块解析方式:NodeJs
module: monaco.languages.typescript.ModuleKind.CommonJS, // 模块导入方式:CommonJS
jsx: monaco.languages.typescript.JsxEmit.React, // 启用JSX(React方式)
typeRoots: ["dist/monaco/@types/"] // 类型定义根目录
});
fetch('dist/monaco/@types/react/index.d.ts') // 抓取React的定义文件
.then(response => response.text())
.then(data => data.replace(`export = React;`, ``)) // 将不必要的模块导出声明删掉(这是用于TypeScript的模拟经典CommonJS、AMD方式的写法)
.then(data => {
if (this._libDisposable.length == 0) {
// 添加引用的库,并加入到待释放列表中
// 首先添加 题型定义 中会用到的一些类型的定义
this._libDisposable.push(monaco.languages.typescript.typescriptDefaults.addExtraLib(`
declare interface IQuestionModel {
[key: string]: any,
id: number,
questionaireId: number,
typeId: number,
order: number,
content: string,
createdAt: Date,
updatedAt: Date,
}
declare interface IAnswerModel {
[key: string]: any,
id: number,
content: string,
sessionId: string,
timeSpent: number,
questionId: number,
ownerIP: string,
createdAt: Date,
updatedAt: Date,
}
declare interface ICreateQuestionTypeComponentProps {
question: IQuestionModel,
questions: IQuestionModel[],
content: string, // save settings in [content]
onContentChanged: (content: string) => void,
}
declare interface IShowQuestionTypeComponentProps {
question: IQuestionModel,
questions: IQuestionModel[],
answers: IAnswerModel[],
createContent: string, // [ICreateQuestionTypeComponentProps.content] settings of the component
content: string, // save answer in [content]
onSubmit: (content: string, nextQuestionIndex?: number) => void,
}
`, 'dist/monaco/@types/questionTypeInterfaces/index.d.ts'))
// 然后,将抓取到的React的定义文件加入到库中
this._libDisposable.push(monaco.languages.typescript.typescriptDefaults.addExtraLib(data, 'dist/monaco/@types/react/index.d.ts'));
}
});
// 关闭语法语义验证(因为缺少部分定义可能导致很多报错)
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false
});
// 根据代码内容,并为代码文件命名,创建并存储编辑器的内容模型
if (!this._createFormEditorModel) {
this._createFormEditorModel = monaco.editor.createModel(model.ceateFormTSX, 'typescript', monaco.Uri.parse('file:///create.tsx'))
}
if (!this._showFormEditorModel) {
this._showFormEditorModel = monaco.editor.createModel(model.showFormTSX, 'typescript', monaco.Uri.parse('file:///show.tsx'))
}
}
也许你注意到了,在Monaco的配置中,目录都是虚拟的,即使已经定义了typeRoot,但编辑器不会真的去自动加载目录内所有的库(毕竟是客户端页面,这是可以理解的),所以后面我们必须手动添加导入的库的内容。
保存代码结果
现在,编辑器已经可以让用户自行设计题型,直接在网页上写代码的体验还是非常不错的。Monaco的智能感知以及相当丰富的快捷命令能够极大的提高编程体验。
但是,代码完成后,如何保存?也许你还记得,前面数据库设计中,有源程序,还有编译后的程序。
这是因为,源程序是这里的TypeScript,方便用户编辑;而前端最终呈现的时候,是通过浏览器,就必须使用编译后的程序。
那么编译后的程序从哪里来呢?Monaco其实已经提供了原生支持。
我们需要调用Monaco的TypeScriptWorker
得到编译后结果,因为最开始初始化的时候我们设定了编译的target是ES5,所以我们在这里得到的也就是主流浏览器都可以兼容的ES5。
private _onSubmit() {
// 只读模式不可提交保存
if (this.props.isOnlyView) return;
this.setState({ isSubmitting: true });
const model = { ...this.state.model };
const pThis = this; // 下面代码的深层闭包中用到了pThis,此处已被省略。
const createFormEditorModel = this._createFormEditorModel;
const showFormEditorModel = this._showFormEditorModel;
monaco.languages.typescript.getTypeScriptWorker()
.then(function (worker: any) {
worker(createFormEditorModel.uri)
.then(function (client: any) {
client.getEmitOutput(createFormEditorModel.uri.toString())
.then(function (res: any) {
// 这里拿到了编译后的创建题目表单代码
model.compiledCreateForm = res.outputFiles[0].text;
monaco.languages.typescript.getTypeScriptWorker()
.then(function (worker: any) {
worker(showFormEditorModel.uri)
.then(function (client: any) {
client.getEmitOutput(showFormEditorModel.uri.toString())
.then(function (res: any) {
// 这里拿到了编译后的展示题目表单代码
model.compiledShowForm = res.outputFiles[0].text;
// 后面是保存到数据库的调用
fetch('api/QuestionType', {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
method: 'POST',
body: JSON.stringify(model)
})
.then(response => {
// ...
})
// ...
});
});
});
});
});
});
}
调用并渲染存储的题型表单
现在只差最后一步,就是将代码转换为最终渲染出来的组件。
DynamicQuestionTypeComponent
类就是专门做这件事情的。该类将代码的抓取封装在内,本身可作为一个React组件使用。
export interface IDynamicQuestionTypeComponentProps {
questionTypeId: number, // 题型ID
formType: 'create' | 'show', // 是 创建题目表单 还是 展示题目表单
componentProps: ICreateQuestionTypeComponentProps | IShowQuestionTypeComponentProps, // 题目表单属性
}
export interface IDynamicQuestionTypeComponentState {
component: any, // 组件实例
}
export class DynamicQuestionTypeComponent
extends HasFetchComponent<IDynamicQuestionTypeComponentProps, IDynamicQuestionTypeComponentState> {
constructor(props: IDynamicQuestionTypeComponentProps) {
super(props);
this.state = {
component: null,
}
}
componentDidMount() {
super.componentDidMount();
// 抓取题型表单代码
fetch('api/QuestionType/' + this.props.questionTypeId)
.then(response => response.json() as Promise<IQuestionTypeModel>)
.then(data => {
let code = this.props.formType == 'create' ? data.compiledCreateForm : data.compiledShowForm;
let context: any = {};
// 注:eval已经不建议被使用,应该换成Function类。这里尊重我当时写的源代码。
let requireComponent = eval(`(function (exports, React) {` + code + `})`);
requireComponent(context, React);
this.setStateWhenMount({ component: context.ExampleQuestionType });
})
.catch(error => console.log(error));
}
render() {
let Component = this.state.component;
let componentProps = this.props.componentProps;
return (
<div>
/* 如果 组件尚未加载成功,显示Loading... */
{!Component && <InfoBar
infoText='Loading...'
/>}
/* 如果组件加载成功,渲染组件,设定组件属性 */
{Component &&
<Component
{...componentProps}
/>
}
</div>
);
}
}
结语
上述看似复杂的流程也确实是经过我对Monaco/TypeScript/React的多番调研才总结并实现出来的,费了很大的功夫。因此才特意写了这一系列的文章。
可以说,这个问卷系统的项目并不复杂,而这里就占了一半多。可能讲用户需求,这样的问卷系统实在小众,而且我也只是做了一个简单的demo,几乎不能实际应用——但这里用到的设计思路,自我感觉还是非常有新意,技术手段也有难度和深度,也许在其他项目中就可以有所应用。
本系列文章到这里就接近尾声,介绍的细节十分有限,但大体思路相比还是能够有所体现,更具体的东西肯定还是查阅代码更为妥当。
接下来还有一篇,仅仅是Demo介绍而已,希望可以给大家一个更清晰的功能展示,找到技术点在作品中实际的映射。