[vue]覺得.net mvc的驗證機制很方便好用嗎?用vue來實現一樣的機制吧

  • 6332
  • 0
  • vue
  • 2018-01-11

[vue]覺得.net mvc的驗證機制很方便好用嗎?用vue來實現一樣的機制吧

前言

筆者用前端框架開發四年有了,雖然web api結合前端框架在開發上很方便,但是驗證的機制就是比.net mvc預設提供的麻煩不少,因為我們可能需要為前端找一些驗證的套件,而後端也需要再做驗證的機制,如果前後端開發是分開的話,只要有新增或修改了驗證的規則,就等於兩邊都要修改,而.net mvc只要為model定義好驗證規則,在搭上razor helper的使用,真的是比起用前端框架方便許多,這邊我就想要使用vue來完成類似.net mvc的驗證效果,驗證規則只定義在後端,前端只需要綁定顯示就好了,接下來就看一下筆者的做法吧。

實做.net mvc的驗證機制

先來看一下.net mvc預設是怎麼實做驗證機制的,在此我實做了一個Employee和Company的Model,如下格式

CompanyModel

    public class CompanyModel
    {
        public int Id { get; set; }

        [Display(Name ="Company Name")]
        [Required]
        public string Name { get; set; }
        [Required]
        public string Address { get; set; }
    }

EmployeeModel

    public class EmployeeModel
    {
        public int Id { get; set; }

        [Display(Name="Employee Name")]
        [Required]
        public string Name { get; set; }

        [Required]
        [EmailAddress]
        public string Email { get; set; }
    }

我們都知道複雜一點的頁面,.net mvc會實做view model,接著來定義一個EmpViewModel吧

    public class EmpViewModel
    {
        public EmployeeModel Employee { get; set; }
        public CompanyModel Company { get; set; }
    }

完成了之後,接著我們新增一個EmpController,並加入Create相關的程式碼

    public class EmpController : Controller
    {
        // GET: Emp/Create
        public ActionResult Create()
        {
            EmpViewModel model = new EmpViewModel
            {

            };
            return View(model);
        }

        // POST: Emp/Create
        [HttpPost]
        public ActionResult Create(EmpViewModel model)
        {
            try
            {
                if (ModelState.IsValid)
                {
                    return RedirectToAction("Index");
                }
                return View(model);
            }
            catch
            {
                return View();
            }
        }
    }

接著我們可以很方便的自動產生View的頁面

但是工具無法幫我們自動產生ViewModel類別的頁面,所以必須自行實做view的內容區塊,看一下重點關於顯示和驗證的部份,在.net mvc只要如下定義就行了

        <div class="form-group">
            @Html.LabelFor(model => model.Employee.Name, htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.Employee.Name, new { htmlAttributes = new { @class = "form-control" } })
                @Html.ValidationMessageFor(model => model.Employee.Name, "", new { @class = "text-danger" })
            </div>
        </div>

Html.ValidationMessageFor綁定了Model所定義的屬性,就會去檢查Model上的驗證機制,效果如下。

使用Vue來完成驗證機制

接著我們就用vue和web api來完成類似.net mvc的驗證機制吧,首先新增一支web api的controller,其餘就延用原本的model,先看一下Web Api的部份

    public class EmployeeController : ApiController
    {
        public EmployeeController()
        {

        }

        public IHttpActionResult Post(EmpViewModel model)
        {
            if (ModelState.IsValid)
            {               
                return Ok(model);
            }
            return BadRequest(ModelState);
        }
    }

當我們驗證不符合的話會response 400並吐回不符合的細節給前端

所以我們可以利用這樣的資訊,再透過vuex來幫助我們實現類似mvc的驗證方式,整個思想大概會如下

底下則是bus.js的程式碼

import Vue from 'vue'
export default new Vue()

接著我定義另一支js來放ajax,當有錯誤為400的時候,使用eventbus來發送一個錯誤格式

import axios from 'axios'
import bus from './bus'

export default {
  post (form) {
    return axios.post(`http://localhost:64508//api/Employee`, form).then(x => x.data).catch(x => {
      if (x.response.status === 400) {
        bus.$emit('modelError', x.response.data.modelState)
      }
    })
  }
}

接著來定義一下ValidateError.vue

<template>
  <div>
    <span style="color:red" v-for="(item,index) in errorColumn" :key="index">
      {{item}}
    </span>
  </div>

</template>

<script>
import _ from 'lodash'
import bus from '../bus'
export default {
  name: 'validateError',
  props: ['column'],
  data () {
    return {
      errorColumn: []
    }
  },
  mounted () {
    bus.$on('modelError', (x) => {
      // _.result可以去取得深層的值,for example of model.Employee.Name
      this.errorColumn = _.result(x, `model.${this.column}`)
    })
  }
}
</script>

<style>

</style>

再看一下Employee.vue的部份

<template>
  <div class="hello">
    <div>
      <label>name</label>
      <input v-model="viewModel.employee.name" @change="nameChange">
      <validate-error column="Employee.Name"></validate-error>
    </div>
    <div>
      <label>email</label>
      <input v-model="viewModel.employee.email">
      <validate-error column="Employee.Email"></validate-error>
    </div>
      <div>
      <label>company name</label>
      <input v-model="viewModel.company.name">
      <validate-error column="Company.Name"></validate-error>
    </div>
      <div>
      <label>address</label>
      <input v-model="viewModel.company.address">
      <validate-error column="Company.Address"></validate-error>
    </div>

    <button @click="submit">submit</button>
  </div>
</template>

<script>
import apiService from '../apiService'
import ValidateError from './ValidateError.vue'

export default {
  name: 'Employee',
  components: {
    ValidateError
  },
  data () {
    return {
      viewModel: {
        employee: {
          id: 1,
          name: '',
          email: ''
        },
        company: {
          id: 1,
          name: '',
          address: ''
        }
      }
    }
  },
  methods: {
    async submit () {
      await apiService.post(this.viewModel)
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

完成之後,就可以看到我們在前端只有binding,驗證的規則都是由後端所定義的

.net mvc開啟ajax驗證方式

上面.net少了一些步驟,所以每次submit總是會直接整個頁面重讀,並未使用ajax的方式,所以我們來把這個部份改成ajax,首先確認一下web.config這兩個部份有開啟

    <add key="ClientValidationEnabled" value="true" />
    <add key="UnobtrusiveJavaScriptEnabled" value="true" />

我們在新增view的時候,預設會在最底下加入

@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
}

而這部份我們需要從nuget下載相關的package

接著為App_Start/BundleConfig.cs新增如下的程式碼

 bundles.Add(new ScriptBundle("~/bundles/jqueryval").Include(
                      "~/Scripts/jquery.validate.js",
                      "~/Scripts/jquery.validate.unobtrusive.js"));

完成之後我們就可以啟用client端的驗證了

使用vue來模擬.net mvc的client驗證

其實之前用vue完成的就已經是ajax的方式,但只針對在submit的時候,如果我們想要為每個控制項做即時反應,就必須再改用比較麻煩的做法了,先看一下web api的部份,在viewmodel的部份我們會需要多加一個modelstate的物件,預先丟給client端做綁定,為了取得類別所定義的attribute,並組成對應的dictionary,所以先建立一支ModelStateHelper來專門處理這類型的事情。

    public class ModelStateHelper
    {
        public static Dictionary<string, Dictionary<string, string>> GetModelStates<T>()
        {
            Dictionary<string, Dictionary<string, string>> modelStates = new Dictionary<string, Dictionary<string, string>>();
            foreach (var prop in typeof(T).GetProperties())
            {
                object[] attrs = prop.GetCustomAttributes(true); //取得所有自訂attribute
                if (attrs == null || attrs.Length == 0) continue;
                Dictionary<string, string> values = new Dictionary<string, string>();

                foreach (Attribute attr in attrs)
                {
                    var getAttributeInfo = GetAttributeInfo(prop, attr);
                    if (getAttributeInfo != null) values.Add(getAttributeInfo.Item1, getAttributeInfo.Item2);
                }
                modelStates.Add(prop.Name, values);
            }
            return modelStates;
        }

        private static Tuple<string, string> GetAttributeInfo(PropertyInfo prop, Attribute attr)
        {
            var dic = new Dictionary<string, string>();
            if (attr is RequiredAttribute)
            {
                return Tuple.Create("Required", (attr as RequiredAttribute).ErrorMessage ?? $"{prop.Name} 必須得輸入");
            }
            if (attr is EmailAddressAttribute)
            {
                return Tuple.Create("Email", (attr as EmailAddressAttribute).ErrorMessage.Replace("{0}", prop.Name) ?? $"{prop.Name} 欄位不是有效的電子郵件地址。");
            }
            return null;
        }
    }

因為我的類別目前只定義required和email,但是實務上絕對會有很多個,就不一一列舉了,接著看一下EmpViewModel的部份,再加入一個我們會回傳的ModelState

    public class EmpViewModel
    {
        public EmployeeModel Employee { get; set; } = new EmployeeModel();
        public CompanyModel Company { get; set; }=new CompanyModel();
        public Dictionary<string,Dictionary<string, Dictionary<string, string>>> ModelStates { get; set; }
    }

這個ModelStates包了多層的Dictionary,最後產生的結果會類似如下格式

  "modelStates": {
    "employeeModel": {
      "name": {
        "required": "Name 必須得輸入"
      },
      "email": {
        "required": "Email 必須得輸入",
        "email": "Email 欄位不是有效的電子郵件地址。"
      }
    },
    "companyModel": {
      "name": {
        "required": "Name 必須得輸入"
      },
      "address": {
        "required": "Address 必須得輸入"
      }
    }
  }

再來是web api的部份,主要是組裝所有前端用到的資訊而已

        public IHttpActionResult Get()
        {
            Dictionary<string, Dictionary<string, Dictionary<string, string>>> emp = new Dictionary<string, Dictionary<string, Dictionary<string, string>>>();

            var empViewModelState = ModelStateHelper.GetModelStates<EmpViewModel>();
            if (empViewModelState.Count > 0) emp.Add(nameof(EmpViewModel), empViewModelState);
            var employeeModelState = ModelStateHelper.GetModelStates<EmployeeModel>();
            if(employeeModelState.Count>0) emp.Add(nameof(EmployeeModel), employeeModelState);
            var companyModelState = ModelStateHelper.GetModelStates<EmployeeModel>();
            if (companyModelState.Count > 0) emp.Add(nameof(CompanyModel), companyModelState);
            EmpViewModel viewModel = new EmpViewModel
            {
                ModelStates = emp
            };
            return Ok(viewModel);
        }

再來看一下前端的部份如何實做,首先為apiService新增一個Get的ajax,再頁面一開啟就去跟後端拿modelState的資料

import axios from 'axios'
import bus from './bus'

export default {
  get () {
    return axios.get(`http://localhost:64508//api/Employee`).then(x => x.data)
  },
  post (form) {
    return axios.post(`http://localhost:64508//api/Employee`, form).then(x => x.data).catch(x => {
      if (x.response.status === 400) {
        bus.$emit('modelError', x.response.data.modelState)
      }
    })
  }
}

接著筆者會再定義一支專門去綁定modelstate的元件,專門做驗證的狀態,我命名為ValidateClient.vue

<template>
  <div class="error-text">
    <div v-show="required">
      {{column['required']}}
    </div>
    <div v-show="email">
      {{column['email']}}
    </div>
  </div>

</template>

<script>
import is from 'is_js'
import bus from '../bus'

export default {
  name: 'clientValidate',
  data () {
    return {
      isDirty: false,
      isSubmitted: false
    }
  },
  props: ['column', 'model'],
  computed: {
    isCheck () {
      return (this.isDirty || this.isSubmitted)
    },
    required () {
      return !this.model && this.isCheck && this.column['required']
    },
    email () {
      return !is.email(this.model) && this.isCheck && this.column['email']
    }
  },
  mounted () {
    bus.$on('submitted', () => {
      this.isSubmitted = true
    })
  },
  beforeUpdate () {
    this.isDirty = true
  }
}
</script>

<style scoped>
.error-text {
  color: red;
}
</style>

因為筆者想要有輸入過值或有submit過之後才會啟用驗證狀況,所以在beforeUpdate事情去偵測,如果綁定的值有異動過,就把isDirty設為true,而這邊一樣使用eventBus來接收調用的元件,如果有按下submit之後,會觸發submitted來通知驗證元件,再驗證的部份client端需要自行實做驗證patten,為求方便筆者這邊使用了is.js來幫忙做驗證的部份,對is.js有興趣者可至(http://is.js.org/)參考。

接著來看一下表單的部份如何實做

<template>
  <div class="hello" v-if="viewModel.employee">
    <div>
      <label>name</label>
      <input v-model="viewModel.employee.name">
      <client-validate :column="viewModel.modelStates.employeeModel.name" :model="viewModel.employee.name">
      </client-validate>
    </div>
    <div>
      <label>email</label>
      <input v-model="viewModel.employee.email">
      <client-validate :column="viewModel.modelStates.employeeModel.email" :model="viewModel.employee.email"></client-validate>
    </div>
    <div>
      <label>company name</label>
      <input v-model="viewModel.company.name">
      <client-validate :column="viewModel.modelStates.companyModel.name" :model="viewModel.company.name">
      </client-validate>
    </div>
    <div>
      <label>email</label>
      <input v-model="viewModel.company.address">
      <client-validate :column="viewModel.modelStates.companyModel.address" :model="viewModel.company.address"></client-validate>
    </div>

    <button @click="submit">submit</button>
  </div>
</template>

<script>
import apiService from '../apiService'
import ClientValidate from './ClientValidate.vue'
import bus from '../bus'

export default {
  name: 'Employee',
  components: {
    ClientValidate
  },
  data () {
    return {
      viewModel: {}
    }
  },
  methods: {
    async submit () {
      await apiService.post(this.viewModel)
      bus.$emit('submitted')
    },
    async get () {
      this.viewModel = await apiService.get()
    }
  },
  async mounted () {
    await this.get()
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>

</style>

當sumit之後去emit了submitted,並且調用ClientValidate,把modelState和input輸入的值丟進clientValidate使用,最後成果如下。

結論

最後想要完成的事情就是把每個欄位的驗證邏輯,都定義在後端,而不是前後端都需要去定義驗證規則,在這部份.net mvc實做的很好,但我們其實也有很多方式可以自行去完成類似的機制,如果有任何更好的想法或做法,再請給予筆者建議哦。