简易问卷系统(三)代码编辑器和组件动态调用

分类: 开发笔记

前言

最开始讲过这个问卷系统的设计特点,可以让用户来自己写问卷的题型组件,而且要利用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>
        );
    }

}

展示题目表单代码

约定getReportTitlegetReportRow分别用于获取该题型在问卷报告中的标题(表头),以及根据作答者的答案内容转换后的报告中的一行数据。

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介绍而已,希望可以给大家一个更清晰的功能展示,找到技术点在作品中实际的映射。