likes
comments
collection
share

Pinia 实战:手写一个简易问卷星

作者站长头像
站长
· 阅读数 49

引言

在前端开发中,数据管理是关键的一环,尤其是在构建复杂应用时。本文将以一个移动端调查问卷项目为例,详细讲解如何使用 Pinia 进行数据管理。我们还会对比 Vuex ,讨论 Pinia 的优势和使用方法。

项目概览

我们的实战项目是类似于问卷星的调查问卷,包括三个主要页面:首页、答题页和评分页,我们并不是有多少题就多少个答题页面,而是这些题都是由一个答题页的子组件去实现的。而在这些组件间的通信如果只是用父子通信,那将会是十分复杂,因此我们在这个项目使用的 pinia 去管理数据,会简洁许多。

整体设计:

清除浏览器原本的样式

众所周知浏览器会自带一些样式,在某些情况下这些css样式会影响我们自己写的css,因此我们不妨在项目开始前直接清除原本的样式。在 assets 目录下创建 style 文件夹,在里面创建 reset.css

body, div, span, header, footer, nav, section, aside, article, ul, dl, dt, dd, li, a, p, h1, h2, h3, h4,h5, i, b, textarea, button, input, select  {
    padding: 0;
    margin: 0;
    list-style: none;
    font-style: normal;
    text-decoration: none;
    border: none;
    color: #313131;
    box-sizing: border-box;
    font-weight: lighter;
    font-family: 'Microsoft YaHei';
    -webkit-tap-highlight-color:transparent;
    &:focus {
        outline: none;
    }
  }
  
  
  html{
    height: 100%;
    width: 100%;
  }
  body{
    height: 100%;
    width: 100%;
    background: url(../images/1-1.jpg) no-repeat;
    background-size: 100% 100%;
  }
  
  .clear:after{
    content: '';
    display: block;
    clear: both;
  }
  
  .clear{
    zoom:1;
  }
  
  .back_img{
    background-repeat: no-repeat;
    background-size: 100% 100%;
  }
  
  .margin{
    margin: 0 auto;
  }
  
  .left{
    float: left;
  }
  
  .right{
    float:right;
  }
  
  .hide{
    display: none;
  }
  
  .show{
    display: block;
  }

此处在消除原本样式的基础上,我们写了全局的样式

使用rem进行适配

在移动端项目中,适配不同屏幕尺寸非常重要。我们可以使用rem单位,通过设置根字体大小来实现适配。我们在assets 目录下创建一个 rem 文件夹,在其中创建一个 index.js 以下是实现这一功能的代码:

(function(doc){
let docEl = doc.documentElement;
    

    doc.addEventListener('DOMContentLoaded',recalc)

    function recalc(){
        let width =docEl.clientWidth;
        docEl.style.fontSize = 20 * (width / 320) + 'px'

    }



})(document);

这段代码在DOMContentLoaded事件触发时,根据屏幕宽度设置根元素的字体大小,从而使得所有使用rem单位的元素随之调整。这样,我们的 CSS 样式可以使用 rem 单位,从而实现响应式布局。

在主函数引入

然后我们再在 main.js 中引入作为全局样式

main.js

import './assets/rem/index.js'
import './assets/style/reset.css'
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

app.mount('#app')

首页设计

接下来,我们设计应用的首页。Home.vue页面如下:

<template>
    <div>
        <ItemContainer parent="home" />
    </div>
</template>

<script setup>
    import ItemContainer from '@/components/ItemContainer.vue';
</script>

<style lang="scss" scoped>
</style>

ItemContainer.vue

<template>
    <div>
        <header class="top_tips">
            <span class="num_tip" v-if="parent === 'home'">第一周</span>
            <span class="num_tip" v-if="parent === 'item'">题目{{ state.itemNum }}</span>
        </header>

        <div v-if="parent === 'home'">
            <div class="home_logo item_container_style"></div>
            <router-link to="/item" class="start button_style"></router-link>
        </div>

        <div v-if="parent === 'item'">
            <!-- 显示答题内容 -->
        </div>
    </div>
</template>

<script setup>
import { useQuestionStore } from '@/store/question';
import { storeToRefs } from 'pinia';

const props = defineProps({
    parent: String
});

const questionStore = useQuestionStore();
const { state } = storeToRefs(questionStore);
</script>

Home.vue 中包含了 ItemContainer.vue 组件,通过传递 parent 属性来控制其渲染内容。在首页,ItemContainer 主要用于展示首页的 UI 元素,比如开始按钮。

Pinia 实战:手写一个简易问卷星

数据管理

在写答题页面前,我们要开始用 Pinia 状态管理。

Pinia介绍

Pinia 是 Vue 3 的官方状态管理库,旨在提供更轻量、易用的状态管理方案。相比于 Vuex,Pinia 有以下优点:

  • 更简洁的 API:Pinia 使用 defineStore 来定义 store,使得 API 更加直观。
  • 内置 TypeScript 支持:Pinia 支持 TypeScript,可以更好地与 TypeScript 项目集成。
  • 更高的性能:Pinia 提供更高效的状态管理,减少了不必要的性能开销。

Pinia 使用

首先安装依赖

npm i pinia

然后去 main.js引入,并注册。

    import './assets/rem/index.js'
    import './assets/style/reset.css'
    import { createApp } from 'vue'
    import App from './App.vue'
    import router from './router'
    import {createPinia} from 'pinia'

    const app = createApp(App)

    app.use(createPinia())
    app.use(router)

    app.mount('#app')

注意 我们引入的是 createPinia !

  • defineStore(id, options): 用于定义一个 store,id 是 store 的唯一标识,options 是 store 的配置项。

  • state: store 的状态,可以使用 reactive 进行定义。

  • actions: store 中的方法,用于修改状态。

  • getters: store 中的计算属性,用于从状态中派生数据。

更多用法可以去参考Pinia | The intuitive store for Vue.js (vuejs.org)

Pinia vs. Vuex

Vuex

  • 需要配置 store 实例和模块化。
  • 使用 mapStatemapGetters 辅助函数来映射 state 和 getters 到组件中。

Pinia

  • 简化了 store 的定义和使用。
  • 直接使用 defineStore 定义 store,使用更直观。

结合项目

我们再去定义一个仓库,在 src 目录下创建一个 store 文件夹,在里面创建 question.js

import { defineStore } from 'pinia'
import {reactive} from 'vue'
import axios from 'axios';

export const useQuestionStore = defineStore('question',() => {
    const state = reactive({
        questionList:[],
        itemNum:1, // 第几题
        answerList:[]  // 选中的答案
    })

    function getQuestionList(){
        axios.get('https://mock.mengxuegu.com/mock/65a7d72cb674c730aaefdcea/example/question')
        .then(res =>{
        // console.log(res.data.questions);
        state.questionList = res.data.questions;
        })
    }

    function setItemNum(){
        state.itemNum++;
    }
    
    function saveAnswerList(index){
        state.answerList.push(index)
    }


    return {
        state,
        getQuestionList,
        setItemNum,
        saveAnswerList


    }

});

在这个项目中我们使用 Pinia 来管理数据,包括题目列表、当前题目编号和用户答案列表。并且设计了几个方法去修改状态。

我们设计 getQuestionList() 去发送请求,从后端获取题目的数据。setItemNum() 去进入到下一题的序号。 saveAnswerList 用这个方法去将每道题选的选项存进数组,在最后统计分数的时候起到大作用。

答题页面

Item.vue 中包含了 ItemContainer.vue 组件,通过传递 item 属性来控制其渲染内容为答题内容。 Item.vue

<template>
    <ItemContainer parent="item" />

</template>

<script setup>
    import ItemContainer from '@/components/ItemContainer.vue';
    import { useQuestionStore } from '@/store/question';

    const questionStore = useQuestionStore();
    questionStore.getQuestionList();

</script>

<style lang="scss" scoped>

</style>    

ItemContainer.vue负责显示每道题目及其选项,并处理用户的答案。通过一个组件显示多道题目,并允许用户选择选项,最终将数据传递到结果页面。

<template>
  <div>
    <header class="top_tips">
      <span class="num_tip" v-if="parent === 'home'">第一周</span>
      <span class="num_tip" v-if="parent === 'item'">题目{{state.itemNum}}</span>
    </header>

    <div v-if="parent === 'home'">
      <div class="home_logo item_container_style"></div>
      <router-link to="/item" class="start button_style"></router-link>
    </div>

    <div v-if="parent === 'item'">
      <div class="item_back item_container_style">
        <div class="item_list_container" v-if="state.questionList.length">
          <header class="item_title">{{state.questionList[state.itemNum-1].topic_name}}</header>
          <ul>
            <li 
              class="item_list" 
              v-for="(item, index) in state.questionList[state.itemNum-1].topic_answer"
              @click="choosed(index)"
            >
              <span class="option_style" :class="{'current': currentNum === index}">{{chooseType(index)}}</span>
              <span class="option_detail">{{item.answer_name}}</span>
            </li>
          </ul>
        </div>
      </div>

      <span class="next_item button_style" v-if="state.itemNum<state.questionList.length" @click="nextItem()"></span>
      <span class="submit_item button_style"  v-else  @click="submit"></span>
    </div>


  </div>
</template>

<script setup>
import { useQuestionStore } from '@/store/question.js'
import { storeToRefs } from 'pinia'
import { ref } from 'vue';
import { useRouter } from 'vue-router';

const questionStore = useQuestionStore()
const { state } = storeToRefs(questionStore)
const router = useRouter();


const chooseType = (i) => {
  switch (i) {
    case 0: return 'A';
    case 1: return 'B';
    case 2: return 'C';
    case 3: return 'D';
    case 4: return 'E';
  }
}

const currentNum = ref(null)
const choosed = (i) => {
  currentNum.value = i
}


defineProps({
  parent: ''
})




// 下一题
const nextItem = () => {
    if (currentNum.value == null) return
    questionStore.setItemNum();
    questionStore.saveAnswerList(currentNum.value);

    currentNum.value = null;


}

// 提交
const submit = () => {
  if (currentNum.value == null) return
  questionStore.saveAnswerList(currentNum.value);
  router.push('/score');

}


</script>

<style lang="less" scoped>
.top_tips{
  position: absolute;
  height: 7.35rem;
  width: 3.25rem;
  top: -1.3rem;
  right: 1.6rem;
  background: url(../assets/images/WechatIMG2.png) no-repeat;
  background-size: 100% 100%;
  .num_tip{
    position: absolute;
    left: 0.48rem;
    bottom: 1.1rem;
    height: 0.7rem;
    width: 2.5rem;
    font-size: 0.6rem;
    font-family: '黑体';
    font-weight: 600;
    color: #a57c50;
    text-align: center;
  }
}
.item_container_style{
  height: 11.625rem;
  width: 13.15rem;
  background-repeat: no-repeat;
  position: absolute;
  top: 4.1rem;
  left: 1rem;
}
.home_logo{
  background: url(../assets/images/1-2.png);
  background-size: 13.142rem 100%;
  background-position: right center;
}
.button_style{
  display: block;
  height: 2.1rem;
  width: 4.35rem;
  background-size: 100% 100%;
  position: absolute;
  top: 16.5rem;
  left: 50%;
  margin-left: -2.175rem;
  background-repeat: no-repeat;
}
.start{
  background-image: url(../assets/images/1-4.png);
}
.item_back{
  background-image: url(../assets/images/2-1.png);
  background-size: 100% 100%;
}
.item_list_container{
  position: absolute;
  width: 8rem;
  height: 7rem;
  top: 2.4rem;
  left: 3rem;
  .item_title{
    font-size: 0.65rem;
    color: #fff;
    line-height: 0.7rem;
  }
  .item_list{
    // margin-top: 0.4rem;
    span{
      display: inline-block;
      font-size: 0.6rem;
      color: #fff;
    }
    .option_style{
      width: 0.725rem;
      height: 0.725rem;
      border: 1px solid #fff;
      border-radius: 50%;
      line-height: 0.725rem;
      text-align: center;
      margin-right: 0.3rem;
      font-size: 0.5rem;
      font-family: Arial;
      &.current{
        background-color: #ffd400;
        color: #575757;
        border-color: #ffd400;
      }
    }
  }
}
.next_item{
  background-image: url(../assets/images/2-2.png);
}
.submit_item{
  background-image: url(../assets/images/3-1.png);
}
</style>

模板部分

<div v-if="parent === 'item'">
  <div class="item_back item_container_style">
    <div class="item_list_container" v-if="state.questionList.length">
      <header class="item_title">{{ state.questionList[state.itemNum-1].topic_name }}</header>
      <ul>
        <li 
          class="item_list" 
          v-for="(item, index) in state.questionList[state.itemNum-1].topic_answer"
          @click="choosed(index)"
        >
          <span class="option_style" :class="{'current': currentNum === index}">{{ chooseType(index) }}</span>
          <span class="option_detail">{{ item.answer_name }}</span>
        </li>
      </ul>
    </div>
  </div>

  <span class="next_item button_style" v-if="state.itemNum < state.questionList.length" @click="nextItem()"></span>
  <span class="submit_item button_style" v-else @click="submit"></span>
</div>

这里我们使用 v-if="parent === 'item'" 条件渲染,确保该内容仅在答题页面显示。通过 state.questionList[state.itemNum-1] 渲染当前题目和选项。state.itemNum 表示当前题目的索引。使用 v-for 遍历当前题目的选项列表,@click="choosed(index)" 允许用户选择选项,并将索引传递给 choosed 函数。根据题目的索引来显示“下一题”按钮或“提交”按钮。v-if="state.itemNum < state.questionList.length" 确保“下一题”按钮仅在未到达最后一道题时显示,反之显示“提交”按钮。

JS部分

import { useQuestionStore } from '@/store/question.js';
import { storeToRefs } from 'pinia';
import { ref } from 'vue';
import { useRouter } from 'vue-router';

const questionStore = useQuestionStore();
const { state } = storeToRefs(questionStore);
const router = useRouter();

const chooseType = (i) => {
  switch (i) {
    case 0: return 'A';
    case 1: return 'B';
    case 2: return 'C';
    case 3: return 'D';
    case 4: return 'E';
  }
};

const currentNum = ref(null);
const choosed = (i) => {
  currentNum.value = i;
};

defineProps({
  parent: ''
});

// 下一题
const nextItem = () => {
  if (currentNum.value == null) return;
  questionStore.setItemNum();
  questionStore.saveAnswerList(currentNum.value);
  currentNum.value = null;
};

// 提交
const submit = () => {
  if (currentNum.value == null) return;
  questionStore.saveAnswerList(currentNum.value);
  router.push('/score');
};
  • useQuestionStore:通过 Pinia 使用 questionStore,这是一个管理问卷状态的 Pinia store。

  • storeToRefs:将 store 的状态转换为响应式引用,方便在组件中使用。

  • chooseType:将选项的索引转换为对应的字母(A, B, C, D, E)。

  • currentNum:存储当前选中的选项索引。

  • choosed:设置 currentNum 为选中的选项的索引。

  • nextItem:在用户点击“下一题”时,将当前选项保存到答案列表,并切换到下一题。

  • submit:在用户点击“提交”时,将当前选项保存到答案列表,并导航到结果页面。

用户选择的答案通过 questionStore.saveAnswerList 方法保存到 Pinia 状态中,确保数据在组件之间的一致性。

最后通过 Vue Router 的 router.push 方法,用户可以在完成答题后导航到结果页面 (/score)。

评分页面

Score.vue 组件负责展示用户的最终得分和评分提示。分数和提示基于用户在答题页面中选择的答案进行计算。

<template>
    <div class="score">
      <header class="your_score">
        <span class="score_name">{{ score }}分</span>
        <div class="result_tip">{{ scoreTip }}</div>
      </header>
    </div>
  </template>
  
  <script setup>
  import { useQuestionStore } from '@/store/question';
  import { storeToRefs } from 'pinia';
  import { computed, reactive } from 'vue';
  
  // 图片资源要当成资源引入
  // import pic from '../assets/images/4-1.jpg'
  
  const state1 = reactive({
    scoreTipsArr: [
      "你说,是不是把知识都还给小学老师了?",
      "还不错,但还需要继续加油哦!",
      "不要嘚瑟还有进步的空间!",
      "智商离爆表只差一步了!",
      "你也太聪明啦,旅梦欢迎你!",
    ]
  });
  
  const questionStore = useQuestionStore();
  const { state } = storeToRefs(questionStore);
  
  // 计算分数
  const score = computed(() => {
    let count = 0;
    const totalQuestions = state.value.questionList.length;
    const fullScore = 100;
    const scorePerQuestion = fullScore / totalQuestions;
  
    for (let i = 0; i < totalQuestions; i++) {
      const userAnswerIndex = state.value.answerList[i];
      const correctAnswer = state.value.questionList[i].topic_answer[userAnswerIndex]?.is_standard_answer;
  
      // 这里假设 correctAnswer 是布尔值,如果是数字 (0 或 1),需要转换
      if (correctAnswer === 1) {
        count++;
      }
    }
  
    return scorePerQuestion * count;
  });
  
  // 根据分数获取提示
  const scoreTip = computed(() => {
    const scoreValue = score.value;
    if (scoreValue < 20) {
      return state1.scoreTipsArr[0];
    } else if (scoreValue < 40) {
      return state1.scoreTipsArr[1];
    } else if (scoreValue < 60) {
      return state1.scoreTipsArr[2];
    } else if (scoreValue < 80) {
      return state1.scoreTipsArr[3];
    } else {
      return state1.scoreTipsArr[4];
    }
  });
  </script>
  
  <style lang="less">
  .score {
    font-size: 40px;
  }
  </style>
  
  <style lang="css">
  /* 可以在一份vue文件上写多份css */
  body {
    background: url('@/assets/images/4-1.jpg');
  }
  </style>

我们定义了一个计算属性 score 并通过调用 store 中存放前面答的每道题选的选项的数组,与获取的问题对象中每道题的标答去比对,用答对的题数乘于每道题的分值,便得到了我们最后的得分!

我们的调查问卷项目便完成了!

Pinia 实战:手写一个简易问卷星

总结

本文以一个移动端调查问卷项目为例,深入讲解了 Pinia 在 Vue 3 项目中的应用。通过详细介绍项目的整体设计、具体页面的实现,以及与 Vuex 的对比,展示了 Pinia 在状态管理中的优势和灵活性。我们首先介绍了项目的总体架构和首页设计,随后详细讲解了 ItemContainer.vueItem.vue 组件的实现,以及它们如何通过 Pinia 实现数据管理。最后,通过分数页面的设计,展示了如何使用 Pinia 进行分数的计算和展示。如果这篇文章对你有帮助的话,可以给小编一个点赞哦😊。

转载自:https://juejin.cn/post/7397019920284680230
评论
请登录