一个问卷调查的小思路

前言

我们的产品指着腾讯问卷的逻辑配置页面说要弄一个一样的页面插入到我们的投票后台系统里面,这里面的逻辑思路还挺有意思的,所以分享出来给大家

需求分析

一开始我是连需求都还搞不清楚的,在腾讯问卷里面折腾了一个晚上才弄明白了他里面的逻辑,但是我们不是一比一复刻出来的,稍做了一些删减,符合我们公司的需求

功能分析

  1. 每个逻辑可以配置多个条件,条件与条件之间可以用且/或连接,条件就是给一个题目添加条件,条件可以为已答 未答 已选 未选,其中已选 未选可以选择题目相应的选项
  1. 每个逻辑可以配置多个结果,结果可以为显示/隐藏某一个题目或者某些选项,跳转到某一道题目,或者直接结束答题,如果条件满足则执行对应的结果

看一下我这边设计的数据结构,原本我是使用js写的,但是为了演示得更加清楚就使用ts重新写一遍

ts 复制代码
// ./types/index.ts

/**
 * 条件的行为
 *
 * @enum {number}
 */
export enum ConditionBehavior {
  NOT_ANSWERED = 'NOT_ANSWERED', // 未答
  ANSWERED = 'ANSWERED', // 已答
  NOT_SELECTED = 'NOT_SELECTED', // 未选
  SELECTED = 'SELECTED' // 已选
}

/**
 * 结果的行为
 *
 * @enum {number}
 */
export enum ResultBehavior {
  SHOW = 'SHOW', // 显示
  HIDE = 'HIDE', // 隐藏
  JUMP = 'JUMP', // 跳转至
  END = 'END' // 结束
}

/**
 * 条件的行为
 *
 * @enum {number}
 */
export enum ConditionRelate {
  /**
   * 是否为开始条件
   */
  START = 'START',
  /**
   * 逻辑与
   */
  AND = 'AND',
  /**
   * 逻辑或
   */
  OR = 'OR'
}

/**
 * 问题和子选项
 *
 * @interface ResultQuestion
 */
export interface ResultQuestion {
  /**
   * 问题Id
   *
   * @type {string}
   * @memberOf ResultQuestion
   */
  questionId: string
  /**
   * 选项Ids
   *
   * @type {string[]}
   * @memberOf ResultQuestion
   */
  optionIds: string[]
}

/*
 * 问题
 *
 * @interface Option
 */
export interface Option {
  id: string
}

/**
 * 条件
 *
 * @interface Condition
 */
export interface Condition {
  /**
   * 条件之间的关系 且/或
   *
   * @type {Relate}
   * @memberOf Condition
   */
  conditionRelate: ConditionRelate
  /**
   * 问题Id
   *
   * @type {string}
   * @memberOf Condition
   */
  questionId: string
  /**
   * 行为 已选/未选/已答/未答
   *
   * @type {ConditionBehavior}
   * @memberOf Condition
   */
  conditionBehavior: ConditionBehavior
  /**
   * 选中/未选的选项Id
   *
   * @type {string[]}
   * @memberOf Condition
   */
  optionIds: string[]
}

/**
 * 结果
 *
 * @interface Result
 */
export interface Result {
  /**
   * 结果的行为
   *
   * @type {ResultBehavior}
   * @memberOf Result
   */
  resultBehavior: ResultBehavior
  /**
   * 问题Id
   *
   * @type {string}
   * @memberOf Result
   */
  questionId: string
  /**
   * 选项的Id集合
   *
   * @type {ResultQuestion[]}
   * @memberOf Result
   */
  logicResultQuestions?: ResultQuestion[]
}

/**
 * 逻辑块
 *
 * @interface LogicBlocks
 */
export interface LogicBlock {
  /**
   * 条件
   *
   * @type {Condition[]}
   * @memberOf LogicBlocks
   */
  logicConditions: Condition[]
  /**
   * 结果
   *
   * @type {Result[]}
   * @memberOf LogicBlocks
   */
  logicResults: Result[]
}

/**
 * 问题
 *
 * @interface Question
 */
export interface Question {
  type: string
  /**
   * 唯一Id
   *
   * @type {string}
   * @memberOf Question
   */
  id: string
  /**
   * 选项集合
   *
   * @type {Option[]}
   * @memberOf Question
   */
  options: Option[]
  /**
   * 答案
   *
   * @type {(string | string[])}
   * @memberOf Question
   */
  value: string | string[]
}

LogicBlocks是逻辑块的数组,logicConditions是条件的数组,logicResults是结果的数组, Question是问题列表的数据

具体实现

首先需要创建一个Class,里面包含了一个返回符合条件问题列表的方法,接受参数为逻辑列表

ts 复制代码
// ./index.ts
import type { LogicBlock, Question } from './types'

class IntelligenceQuestionHandler {
  // 问题列表
  sourceQuestionList: Question[] = []
  /**
   * @param {Array} questionList 问题列表
   */
  constructor(questionList: Question[]) {
    this.sourceQuestionList = questionList
  }
  
  /**
   * @description preview模式,会显示[startQuestionId]题到题目列表最后
   * @param {*} logicBlocks 逻辑列表
   * @returns {Array} questionList 题目列表
   */
  handlePreviewMode(logicBlocks: LogicBlock[]) {
    const tartgetQuestionList: Question[] = []
    // ...逻辑处理
    return tartgetQuestionList
  }
}

接下来就是处理中间逻辑了,但是在开始处理前我们需要做一步比较重要的准备工作,就是先处理逻辑块中问题id和选项id之间的映射关系,这部至关重要

处理问题与选项映射关系

  1. 问题Id和逻辑模块的index映射 {questionId: [0,1,2]}
  2. 选项Id和逻辑模块的index 选项Id和逻辑模块下结果的index 映射 {optionId: results[{logicIndex, resultIndex}]}
ts 复制代码
// ./index.ts
import type { LogicBlock } from './types'

/**
 * 问题与逻辑块的映射
 *
 * @interface LogicBlocksMapping
 */
interface LogicBlocksMapping {
  [questionId: string]: {
    conditions: number[]
  }
}

/**
 * 选项与Condition的映射
 *
 * @interface OptionVisibleMapping
 */
interface OptionVisibleMapping {
  [optionId: string]: {
    results: {
      /**
       * 哪个逻辑模块
       *
       * @type {number}
       */
      logicIndex: number
      /**
       * 模块的哪个结果
       *
       * @type {number}
       */
      resultIndex: number
    }[]
  }
}

class IntelligenceQuestionHandler {
  // ...其余代码
  // 问题与逻辑块的映射
  logicBlocksMapping: LogicBlocksMapping = {}
  // 选项与Condition的映射
  optionVisibleMapping: OptionVisibleMapping = {}
  
  handlePreviewMode(logicBlocks: LogicBlock[]) {
    // ...其余代码
    this.handleLogics(logicBlocks)
  }

  /**
  * 一次循环把问题相关的logic和result收集在一起
  */
  handleLogics(logicBlocks: LogicBlock[]) {
    const logicBlocksMapping: LogicBlocksMapping = {}
    const optionVisibleMapping: OptionVisibleMapping = {}
    for (
      let logicIndex = 0, logicLength = logicBlocks.length;
      logicIndex < logicLength;
      logicIndex++
    ) {
      const logic = logicBlocks[logicIndex]
      const logicConditions = logic.logicConditions
      const logicResults = logic.logicResults
      if (logicConditions && logicConditions.length) {
        for (
          let conditionIndex = 0, conditionLength = logicConditions.length;
          conditionIndex < conditionLength;
          conditionIndex++
        ) {
          const condition = logicConditions[conditionIndex]
          if (!logicBlocksMapping[condition.questionId]) {
            logicBlocksMapping[condition.questionId] = {
              conditions: [logicIndex]
            }
          } else {
            logicBlocksMapping[condition.questionId].conditions.push(logicIndex)
          }
        }
      }
      if (logicResults && logicResults.length) {
        for (
          let resultIndex = 0, resultLength = logicResults.length;
          resultIndex < resultLength;
          resultIndex++
        ) {
          const result = logicResults[resultIndex]
          // 显示隐藏是以选项为基础单位去判断的
          if (
            result.resultBehavior === 'SHOW' ||
            result.resultBehavior === 'HIDE'
          ) {
            const logicResultQuestions = result.logicResultQuestions
            logicResultQuestions &&
              logicResultQuestions.forEach((lrq) => {
                // 选项类型记录每个选项对应的逻辑块和结果
                if (lrq.optionIds && lrq.optionIds.length) {
                  lrq.optionIds.forEach((optionId) => {
                    if (!optionVisibleMapping[optionId]) {
                      optionVisibleMapping[optionId] = {
                        results: [{ logicIndex, resultIndex }]
                      }
                    } else {
                      optionVisibleMapping[optionId].results.push({
                        logicIndex,
                        resultIndex
                      })
                    }
                  })
                } else {
                  // 如果是文本类型,使用questionId保存
                  if (!optionVisibleMapping[lrq.questionId]) {
                    optionVisibleMapping[lrq.questionId] = {
                      results: [{ logicIndex, resultIndex }]
                    }
                  } else {
                    optionVisibleMapping[lrq.questionId].results.push({
                      logicIndex,
                      resultIndex
                    })
                  }
                }
              })
          }
        }
      }
    }
    this.logicBlocksMapping = logicBlocksMapping
    this.optionVisibleMapping = optionVisibleMapping
  }
}

处理完以后就可以正式去判断配置的逻辑了

判断显示/隐藏

  1. 循环问题列表,循环所有问题选项
  2. 通过选项id和模块index的映射表找到最后一个当前选项id的条件和结果
  3. 至于为什么找最后一个,因为后面的覆盖前面的
ts 复制代码
class IntelligenceQuestionHandler {
  // ...其余代码
  handlePreviewMode(logicBlocks: LogicBlock[]) {
    // ...其余代码
    const questionIndex = 0
    for (
      let i = questionIndex, length = this.sourceQuestionList.length;
      i < length;
      i++
    ) {
      const result = this.handleQuestion(tartgetQuestionList, logicBlocks, i)
    }
    return tartgetQuestionList
  }
}

经过handleQuestion方法后tartgetQuestionList里面就是处理完后正确展示的问题 (未处理跳转和结束情况),现在来写handleQuestion方法

ts 复制代码
   /**
   * @description 处理单个问题
   * @param {Array} tartgetQuestionList 目标题目列表
   * @param {Array} logicBlocks 逻辑块列表
   * @param {Number} questionIndex 当前题目下标
   */
  private handleQuestion(
    tartgetQuestionList: Question[],
    logicBlocks: LogicBlock[],
    questionIndex: number
  ) {
    const question = this.sourceQuestionList[questionIndex]
    // 判断是否有需要显示的选项
    const visibleResult = this.checkVisibleLogicMatched(
      tartgetQuestionList,
      logicBlocks,
      question
    )
    // 如果有需要显示的选项则加入列表
    if (
      visibleResult &&
      (visibleResult === 'SHOW_QUESTION' || visibleResult.length)
    ) {
      // 文本类型需要显示题目标志
      if (visibleResult === 'SHOW_QUESTION') {
        tartgetQuestionList.push(question)
      } else {
        question.options = visibleResult
        tartgetQuestionList.push(question)
      }
    }
  }
  
  /**
   * @description 处理逻辑,判断当前题目是否需要显示
   *
   * @param {Array} currentList 当前题目列表
   * @param {Array} logicBlocks 逻辑块列表
   * @param {String} question 当前题目
   * @returns {'SHOW' | 'HIDE'} result
   */
  private checkVisibleLogicMatched(
    currentList: Question[],
    logicBlocks: LogicBlock[],
    question: Question
  ) {
    if (!logicBlocks || !logicBlocks.length) {
      // 没有逻辑直接返回
      if (this.checkTextType(question)) {
        return 'SHOW_QUESTION'
      } else {
        return question.options
      }
    }
    const optionVisibleMapping = this.optionVisibleMapping
    const resultOptionList: Option[] = []
    // 获取问题的options,如果是选项类型,就使用question.options,如果是文本类型,使用[question.id],将整个题目当成option去判断
    const options =
      question.options && question.options.length
        ? question.options
        : [{ id: question.id }]
    // 逐个判断是否需要显示
    for (
      let optionIndex = 0, optionLength = options.length;
      optionIndex < optionLength;
      optionIndex++
    ) {
      const option = options[optionIndex]
      const optionId = option.id
      if (optionVisibleMapping[optionId]) {
        // 看最后一个SHOW/HIDE的条件结果
        const { results } = optionVisibleMapping[optionId]
        const result = results[results.length - 1]
        // 获取当前逻辑块下标和逻辑结果下标
        const { logicIndex, resultIndex } = result
        // 获取逻辑条件
        const currentCondictions = logicBlocks[logicIndex].logicConditions
        // 获取获取逻辑结果
        const currentResult = logicBlocks[logicIndex].logicResults[resultIndex]
        // 判断条件是否符合
        const matched = this.checkConditionsMatched(
          currentList,
          currentCondictions
        )
        if (!matched) {
          // 条件设置的是SHOW和HIDE,所以不符合的时候,返回相反结果
          // 如果命中,并且是显示,选项加入列表
          if (currentResult.resultBehavior === 'HIDE') {
            if (this.checkTextType(question)) {
              return 'SHOW_QUESTION'
            } else {
              resultOptionList.push(option)
            }
          }
        } else {
          // 如果命中,并且是显示,选项加入列表
          if (currentResult.resultBehavior === 'SHOW') {
            if (this.checkTextType(question)) {
              return 'SHOW_QUESTION'
            } else {
              resultOptionList.push(option)
            }
          }
        }
      } else {
        resultOptionList.push(option)
      }
    }
    return resultOptionList
  }
  
    /**
   * @description 查询是否所有条件都符合
   * @param {Array} currentList 当前题目列表
   * @param {Array} condition 条件列表
   */
  private checkConditionsMatched(
    currentList: Question[],
    conditions: Condition[]
  ) {
    return conditions.reduce((accumulator, condition) => {
      // 依次判断条件
      const { conditionRelate, questionId } = condition
      if (conditionRelate === 'START' || conditionRelate === 'AND') {
        const res =
          accumulator &&
          this.checkConditionMatched(
            condition,
            this.getQuestionItem(currentList, questionId)
          )
        return res
      } else {
        return (
          accumulator ||
          this.checkConditionMatched(
            condition,
            this.getQuestionItem(currentList, questionId)
          )
        )
      }
    }, true)
  }
  
  /**
   * @description 判断条件是否符合
   * @param {Object} condition 条件
   * @param {Object} sourceQuestion 当前题目
   */
  private checkConditionMatched(
    condition: Condition,
    sourceQuestion: Question
  ) {
    const { questionId, conditionBehavior, optionIds } = condition
    // 找到对应的题目
    if (sourceQuestion && questionId === sourceQuestion.id) {
      // 如果是未选/未答无答案则符合
      if (
        (conditionBehavior === 'NOT_ANSWERED' ||
          conditionBehavior === 'NOT_SELECTED') &&
        (!sourceQuestion || this.checkEmpty(sourceQuestion.value))
      ) {
        return true
      }
      // 如果是已答有答案则符合
      if (conditionBehavior === 'ANSWERED') {
        return !this.checkEmpty(sourceQuestion.value)
      // 如果是已答无答案则符合
      } else if (conditionBehavior === 'NOT_ANSWERED') {
        return this.checkEmpty(sourceQuestion.value)
      } else if (conditionBehavior === 'SELECTED') {
        // 如果是已选有答案并且选项有交集则符合
        if (this.checkEmpty(sourceQuestion.value)) {
          return false
        }
        return this.checkArrayIntersection(
          optionIds,
          sourceQuestion.value as string[]
        )
        // 如果是未选无答案则符合或者选项无交集则符合
      } else if (conditionBehavior === 'NOT_SELECTED') {
        if (this.checkEmpty(sourceQuestion.value)) {
          return true
        }
        return !this.checkArrayIntersection(
          optionIds,
          sourceQuestion.value as string[]
        )
      }
    }
    return false
  }
  
  /**
   * @description 判断两个数组是否有交集
   */
  private checkArrayIntersection(arr1: any[], arr2: any[]) {
    const set = new Set(arr1)
    for (let i = 0; i < arr2.length; i++) {
      if (set.has(arr2[i])) {
        return true
      }
    }
    return false
  }
  
  // 判断是否为文本类型
  private checkTextType(question: Question) {
    return question.type === 'text' || question.type === 'textarea'
  }

其实这一块看着代码多,但逻辑梳理下来就是通过映射表找到对应选项的条件与结果,判断条件是否符合,这里有两个情况,如果结果行为是显示,那么条件符合则是添加到展示的问题列表中,如果结果行为是隐藏,那么条件不符合才添加到展示的问题列表中

处理跳转/结束

  1. 跳转和结束就是通过映射表,找到当前问题id对应的逻辑模块,结果里面有没有跳转/结束,如果这个逻辑条件符合则跳转/结束
  2. 这里有个小细节就是我们在找问题id对应的逻辑模块可以从后往前循环,因为后面的优先级更高
  3. 重复代码就不写了
ts 复制代码
/**
   * @description 处理单个问题
   * @param {Array} tartgetQuestionList 目标题目列表
   * @param {Array} logicBlocks 逻辑块列表
   * @param {Number} questionIndex 当前题目下标
   * @returns {'END' | number | undefined} result "END":结束、"HIDE":隐藏、number: 跳转、undefined:下一个
   */
  private handleQuestion(
    tartgetQuestionList: Question[],
    logicBlocks: LogicBlock[],
    questionIndex: number
  ) {
    const question = this.sourceQuestionList[questionIndex]
    // 判断是否有需要显示的选项
    const visibleResult = this.checkVisibleLogicMatched(
      tartgetQuestionList,
      logicBlocks,
      question
    )
    // 如果有需要显示的选项则加入列表
    if (
      visibleResult &&
      (visibleResult === 'SHOW_QUESTION' || visibleResult.length)
    ) {
      // 文本类型需要显示题目标志
      if (visibleResult === 'SHOW_QUESTION') {
        tartgetQuestionList.push(question)
      } else {
        question.options = visibleResult
        tartgetQuestionList.push(question)
      }
      
      // 从这里开始是跳转/END对应代码 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
      
      // 判断跳转或者END
      const result = this.checkJumpOrEndLogicMatched(
        tartgetQuestionList,
        logicBlocks,
        question
      )
      if (result) {
        const { questionId, resultBehavior } = result
        if (resultBehavior === 'JUMP') {
          const targetIndex = this.getSourceQuestionIndex(questionId)
          // 不能往回跳
          if (targetIndex > questionIndex) {
            questionIndex = targetIndex - 1
            return questionIndex
          }
        } else if (resultBehavior === 'END') {
          return 'END'
        }
      }
    }
  }
  
    /**
   * @description 处理逻辑,判断当前题目是否需要跳转
   *
   * @param {Array} currentList 当前题目列表
   * @param {Array} logicBlocks 逻辑块列表
   * @param {Object} question 当前题目
   * @returns {{questionId, resultBehavior}} result
   */
  private checkJumpOrEndLogicMatched(
    currentList: Question[],
    logicBlocks: LogicBlock[],
    question: Question
  ) {
    if (!logicBlocks || !logicBlocks.length) return undefined
    const logicBlocksMapping = this.logicBlocksMapping
    if (logicBlocksMapping[question.id]) {
      const { conditions } = logicBlocksMapping[question.id]
      if (!conditions || !conditions.length) return undefined
      // 从后往前找
      for (let i = conditions.length - 1; i >= 0; i--) {
        // 获取当前逻辑块下标和逻辑结果下标
        const logicIndex = conditions[i]
        // 获取逻辑条件
        const currentCondictions = logicBlocks[logicIndex].logicConditions
        // 查看逻辑结果列表是否包含跳转或者结束逻辑
        const jumpResult = logicBlocks[logicIndex].logicResults.find(
          (item) => item.resultBehavior === 'JUMP'
        )

        const endResult = logicBlocks[logicIndex].logicResults.find(
          (item) => item.resultBehavior === 'END'
        )
        
        // 结束
        if (endResult) {
          // 判断条件是否符合
          const matched = this.checkConditionsMatched(
            currentList,
            currentCondictions
          )
          // 如果符合,则返回
          if (matched) {
            return endResult
          }
        }
        
        // 跳转
        if (jumpResult) {
          // 判断条件是否符合
          const matched = this.checkConditionsMatched(
            currentList,
            currentCondictions
          )
          // 如果符合,则返回
          if (matched) {
            return jumpResult
          }
        }
      }
    }
    return undefined
  }
  
    /**
   * @description 获取问题索引
   */
  private getSourceQuestionIndex(questionId: string) {
    if (questionId === undefined) return 0
    return this.sourceQuestionList.findIndex((item) => item.id === questionId)
  }

测试一下

js 复制代码
const questions = [
  {
    id: '1',
    options: [
      {
        id: '1-1'
      }
    ],
    type: 'radio',
    value: '1-1'
  },
  {
    id: '2',
    options: [
      {
        id: '2-1'
      }
    ],
    type: 'radio',
    value: ''
  },
  {
    id: '3',
    options: [
      {
        id: '3-1'
      }
    ],
    type: 'radio',
    value: ''
  }
]

const logic = [
  {
    logicConditions: [
      {
        conditionBehavior: 'ANSWERED',
        conditionRelate: 'START',
        questionId: '1',
        optionIds: []
      }
    ],
    logicResults: [
      {
        resultBehavior: 'JUMP',
        questionId: '3'
      },
      {
        resultBehavior: 'END'
      }
    ]
  }
]

const handle = new IntelligenceQuestionHandler(questions)
const list = handle.handlePreviewMode(logic)
console.log(list)

我这个测试用例是如果id为1的问题已答则跳转到id为3的问题和结束,那么结束优先级更大所以,正确的结果应该是剩下id为1的问题,问答结束

这是我最终的输出结果,你们也可以修改别的尝试一下,有bug欢迎指出

相关推荐
小满zs3 小时前
Zustand 第五章(订阅)
前端·react.js
涵信4 小时前
第一节 基础核心概念-TypeScript与JavaScript的核心区别
前端·javascript·typescript
谢尔登4 小时前
【React】常用的状态管理库比对
前端·spring·react.js
编程乐学(Arfan开发工程师)4 小时前
56、原生组件注入-原生注解与Spring方式注入
java·前端·后端·spring·tensorflow·bug·lua
小公主4 小时前
JavaScript 柯里化完全指南:闭包 + 手写 curry,一步步拆解原理
前端·javascript
姑苏洛言6 小时前
如何解决答题小程序大小超过2M的问题
前端
GISer_Jing7 小时前
JWT授权token前端存储策略
前端·javascript·面试
开开心心就好7 小时前
电脑扩展屏幕工具
java·开发语言·前端·电脑·php·excel·batch
拉不动的猪7 小时前
es6常见数组、对象中的整合与拆解
前端·javascript·面试
GISer_Jing7 小时前
Vue Router知识框架以及面试高频问题详解
前端·vue.js·面试