如何在JavaScript中创建自定义,动态,基于反射的ViewModel验证?

时间:2014-09-18 16:08:05

标签: javascript validation reflection knockout.js

问题摘要

有没有办法使用反射来识别应用了特定ko.extender方法的可观察属性?
换句话说,我想对需要验证的Knockout JS ViewModel类的所有属性执行自定义验证(不保留需要验证的所有属性的列表)。


我知道您可以使用Knockouts extender原型语法扩展Knockout observable,如下所示:

ko.extenders.myCustomFunction = function(target, data) {};

然后,使用如下语法将扩展应用于observable:

this.SomeObservable = ko.observable("defaultValue").extend({myCustomFunction: "dataArgument"});

我在下面的代码中实现了这种模式。

示例代码

为简单起见,让我们将讨论局限于检测具有必需验证规则的可观察属性(尽管将来会有其他验证类型:Regex,Range ,多场等。)。

以下是示例小提琴中的ViewModel类的精简版本:

var FormViewModel = function() {
    var self = this;
    self.FirstName = ko.observable("").extend(
        { required: "FirstName" }
    );
    self.LastName = ko.observable("").extend(
        { required: "LastName" }
    );
    self.BirthDate = ko.observable(""); // Not required

    self.IsValid = ko.pureComputed(function() {
        //Omitted for brevity (included later)
    }, self);
};

在上面的ViewModel中有各种类型,我唯一感兴趣的是查看IsValid pureComputed函数的那些是可观察的并且是必需的

我已经声明了所需的扩展器:

ko.extenders.required = function (target, data) {
    //add some sub-observables to our observable
    target.ValidationState = new ValidationItemViewModel();
    target.ValidationState.PropertyName(data.fieldName);
    target.ValidationMessage = ko.pureComputed(function () {
        return target.ValidationState.ValidationMessage();
    }, this);

    //define a function to do validation
    function validate(newValue) {
        target.ValidationState.IsValid(newValue && !data.initialState() ? true : false);
        if(data.initialState()) {
            data.initialState(false);
        } // end if
    }

    //initial validation. Skip this to avoid errors on load
    //validate(target());

    //validate whenever the value changes, the value of the property (which came from the form will be passed as the argument to validate).
    target.subscribe(validate);

    //return the original observable
    return target;
};

我有IsValid函数的实现,这让我走得很远:

    var members = [];
    for (var item in self) {
        //attempt to identify the observables, which works
        if(!ko.isComputed(self[item]) && ko.isObservable(self[item])) {
            // try to detect required extensions
            //TODO: if(self[item].extenders.hasOwnProperty("required");
            members.push({name: item, type: typeof self[item], instance: self[item]});
        } // end if
    } // end for loop
    console.log(members);
    return members;

这对于将可观察属性返回给我是有效的,但我无法弄清楚如何访问属性,这些属性会显示已使用控制台使用required扩展程序扩展可观察对象或在监视窗口中进行检查我无法在网上找到有关此方法的任何资源。

完整代码(外部JS小提琴): http://jsfiddle.net/xDaevax/rrLphthg/

完整代码(Stack Snippet):



    //An initial state to control bio rendering and not show form errors on the first load.
    var initialState = true;
$(function () {
    //Set focus on the first element
    $("[tabindex='1']", $("form")).focus();
    $(document).on("click", "#_submit", function(e, data) {
       return false; //For this fiddle, prevent submission
    });
    $(document).on("click", "#_clear", function(e, data) {
        e.preventDefault();
        console.log("Cleared");
        viewModel.Clear();
    });
    $(".datepicker").datepicker({ showAnim: "puff", changeYear: true, yearRange: ("1930:" + new Date().getFullYear()), maxDate: new Date() });
    ko.applyBindings(viewModel);
});

var GenderOptions = {
    Unspecified: 0,
    Male: 1, 
    Female: 2
};
GenderOptions.GetName = function(value) {
    switch(value) {
        case 0:
            return 'Unspecified';
        case 1:
            return 'Male';
        case 2:
            return 'Female';
        default:
            return 'Unspecified';
    } // end switch
};

var ValidationItemViewModel = function () {
    var self = this;
    self.IsValid = ko.observable(true);
    self.PropertyName = ko.observable("");
    self.MessageClass = ko.pureComputed(function() {
        return self.IsValid() ? "validation-inactive" : "validation-active";
    }, self);
    self.ValidationMessage = ko.pureComputed(function () {
        if (!self.IsValid()) {
            return self.PropertyName() + " is required.";
        } else {
            return "";
        } // end if/else
    }, self);
};

ko.extenders.withName = function (target) {
    target.Name = function() {
        return GenderOptions.GetName(target());
    };
    return target;  
};

ko.extenders.required = function (target, data) {
    //add some sub-observables to our observable
    target.ValidationState = new ValidationItemViewModel();
    target.ValidationState.PropertyName(data.fieldName);
    target.ValidationMessage = ko.pureComputed(function () {
        return target.ValidationState.ValidationMessage();
    }, this);

    //define a function to do validation
    function validate(newValue) {
        target.ValidationState.IsValid(newValue && !data.initialState() ? true : false);
        if(data.initialState()) {
            data.initialState(false);
        } // end if
    }

    //initial validation. Skip this to avoid errors on load
    //validate(target());

    //validate whenever the value changes, the value of the property (which came from the form will be passed as the argument to validate).
    target.subscribe(validate);

    //return the original observable
    return target;
};

var FormViewModel = function () {
    var self = this;
    self.InitialState = ko.observable(initialState);
    self.AvailableGenders = ko.observableArray([GenderOptions.Unspecified, GenderOptions.Male, GenderOptions.Female]);
    self.FirstName = ko.observable("").extend({
        required: {fieldName: "FirstName", initialState: self.InitialState}
    });
    self.LastName = ko.observable("").extend({
        required: {fieldName: "LastName", initialState: self.InitialState}
    });
    self.Gender = ko.observable(GenderOptions.Unspecified).extend({withName: true});
    self.BirthDate = ko.observable("");
    self.ValidationFields = ko.computed(function() {
        //Build a list of valid members to check the validation status of
        var members = [];
        for (var item in self) {
            if(!ko.isComputed(self[item]) && ko.isObservable(self[item])) {
                
                members.push({name: item, type: typeof self[item], instance: self[item]});
            } // end if
        } // end for loop
        console.log(members);
        return members;
    }, self);
    self.IsValid = ko.computed(function() {
        //For now, hack in a dependency to trick the pureComputed into re-evaluating when the view model is changed instead of returning a simple boolean value.
        var memberData = self.ValidationFields();
        return true;
    }, self);
    self.UserBio = ko.pureComputed(function() {
        var bioData = "";
        if(self.IsValid() && !self.InitialState()) {
            var bioGender = "Eunich";
            var bioExperience = "";
            var bioExperienceDetail = "";
            var bioGenderDetail = "";
            var bioGender = "";
            var age = moment().diff(moment(self.BirthDate().replace("/",""), "MMDDYYYY"), 'years');
            if(age < 18) {
                bioExperience = " <b>Trainee</b>";
                bioGender = self.Gender() == GenderOptions.Male ? "Young Lad" : "Young Lady";
                bioExperienceDetail = "At your young age {0}, you have a future full of possibilities.";
            } else if(age >= 18 && age < 25) {
                bioExperience = "n <b>Apprentice</b>";
                bioGender = self.Gender() == GenderOptions.Male ? "Squire" : "Student";
                bioExperienceDetail = "Now that you are of age {0}, you are in training for a promising destiny.";
            } else if (age >= 25 && age < 35) {
                bioExperience = " <b>Land Owner</b>";
                bioGender = self.Gender() == GenderOptions.Male ? "Duke" : "Dame";
                bioExperienceDetail = "Finally at your age {0} your studies have paid off and you now have a title you can be proud of!   But what adventures are yet to come?";
            } else if (age >= 35 && age < 50) {
                bioExperience = "n <b>Official</b>";
                bioGender = self.Gender() == GenderOptions.Male ? "Lord" : "Lady";
                bioExperienceDetail = "Your escapades and adventures over the years have been extraordinary!  By this time {0}, you are beginning to slow down and relax as the future unfolds.";
            } else {
                bioExperience = "n <b>Elder</b>";
                bioGender = self.Gender() == GenderOptions.Male ? "Master" : "Mistress";
                bioExperienceDetail = "At your ripe old age {0}, it's surprising you are still kickin' it.  You do have some wisdom about you though, perhaps it would be useful to someone...";
            } // end if/else
            if(self.Gender() == GenderOptions.Unspecified) {
                bioGender = "Eunich";
            } // end if
            bioData = "You are <b>" + bioGender + "</b> <strong>" + self.FirstName() +
                "</strong>, a" + bioExperience + " of the house of <strong>" + self.LastName() + "</strong>." +
                " <br />" + bioExperienceDetail;
            bioData = bioData.replace("{0}", "(" + age + ")");
        } else {
            bioData = "No data available";
        } // end if/else
        
        return bioData;
    }, self);
    
    self.Clear = function() {
        self.InitialState(true);
        self.FirstName("");
        self.LastName("");
        self.Gender(GenderOptions.Unspecified);
        self.BirthDate("");
    };
};

var viewModel = new FormViewModel();
&#13;
.main {
    width: 100%;
    margin: 0px auto;
    padding: 4px 0px;
    color: #121212;
    font-family: Candara, Arial, Sans-Serif;
    font-size: small;
    display: block;
}
.main H1 {
    font-size: 130%;
    font-weight: bold;
    margin: 0px auto 5px 5px;
    color: #565656;
}
.main H2 {
    display: block;
    width: 50%;
    margin: 6px auto 10px auto;
    padding: 2px;
    text-align: center;
    font-weight: bold;
    border: outset 1px #dedede;
    background: #453326 url("https://code.jquery.com/ui/1.11.1/themes/mint-choc/images/ui-bg_gloss-wave_25_453326_500x100.png") 50% 50% repeat-x;
    border-radius: 3px;
    font-size: 122%;
    color: white;
}
.main INPUT[type='text'] {
    border: solid 1px #ababab;
    border-radius: 2px;
    width: 160px;
}
.main INPUT[type='text']:HOVER {
    background: #619226 url("https://code.jquery.com/ui/1.11.1/themes/mint-choc/images/ui-bg_highlight-soft_20_619226_1x100.png") 50% top repeat-x;
    border: solid 1px #add978;
}
.main MARK {
    background-color: inherit;
}
.main INPUT[type='submit'], .main INPUT[type='reset'], .main INPUT[type='button'] {
    padding: 3px 5px;
    border: 1px solid #695444;
    background: #1c160d url("https://code.jquery.com/ui/1.11.1/themes/mint-choc/images/ui-bg_gloss-wave_20_1c160d_500x100.png") 50% 50% repeat-x;
    box-shadow: 2px 1px 2px rgba(190, 190, 190, .8);
    margin: 0 4px;
    cursor: pointer;
    color: #9bcc60;
}
.main INPUT[type='submit']:HOVER, .main INPUT[type='reset']:HOVER, .main INPUT[type='button']:HOVER {
    background: url("https://code.jquery.com/ui/1.11.1/themes/mint-choc/images/ui-bg_gloss-wave_30_44372c_500x100.png") repeat-x scroll 50% 50% #44372C;
    box-shadow: 4px 1px 3px rgba(50, 50, 50, .8);
}
.main INPUT[type='submit']:ACTIVE, .main INPUT[type='reset']:ACTIVE, .main INPUT[type='button']:ACTIVE {
    box-shadow: 2px 0px 2px rgba(50, 50, 50, .8);
    background-color: rgba(76, 151, 183, .8);
}
.main .form-liner {
    margin: 0px auto;
    text-indent: 0px;
    padding: 0px 14px;
    width: 90%;
    background-color: #efefef;
    height: 280px;
}
.form-liner P {
    margin-bottom: 8px;
}
.form-liner .form-item {
    display: block;
    width: auto;
    margin: 0px auto 9px auto;
    padding: 5px 0;
}
.form-liner .item-error {
    border: solid 1px rgb(220, 100, 100) !important;
}
.form-liner .validation-message, .form-liner LABEL {
    display: inline-block;
    height: 15px;
    vertical-align: top;
    line-height: 13px;
}
.form-liner .validation-message {
    font-size: 85%;
    margin-left: 6px;
    font-family: Verdana, Arial, Sans-Serif;
}
.form-liner .form-item LABEL {
    width: 15%;
    max-width: 110px;
    min-width: 80px;
    text-align: right;
    margin-right: 10px;
    font-weight: bold;
}
.form-liner .form-item .radio-list INPUT[type='radio'] {
    display: inline-block;
}
.form-liner .form-item .vertical {
    display: inline-block;
}
.form-liner .form-item .radio-list LABEL {
    display: inline;
}
.form-item .input-option {
    display: inline-block;
}
.validation-inactive {
    color: inherit;
}
.validation-active {
    color: red;
}
.action-wrapper {
    padding: 4px;
    display: block;
    border: inset 1px rgb(250, 133, 2);
    background: #453326 url("https://code.jquery.com/ui/1.11.1/themes/mint-choc/images/ui-bg_gloss-wave_25_453326_500x100.png") 50% 50% repeat-x;
    border-radius: 2px;
}
.modal {
    border-radius: 6px;
    border: 1px solid #695444;
    width: 70%;
    margin: 0px auto;
    background: #201913 url("https://code.jquery.com/ui/1.11.1/themes/mint-choc/images/ui-bg_inset-soft_10_201913_1x100.png") 50% bottom repeat-x;
}
.bio {
    height: 200px;
}
.content {
    border:1px solid #9c947c;
    background: #44372c url("https://code.jquery.com/ui/1.11.1/themes/mint-choc/images/ui-bg_gloss-wave_30_44372c_500x100.png") -5% -5% repeat-x;
    height: 73%;
    border-radius: 6px;
    margin: 2px 4px;
    color: white;
    padding: 4px;
}
.bio B {
    color: #9bcc60;
}
.bio STRONG {
    border-bottom: dashed 1px;
    color: #609bcc; 
}
&#13;
<link href="https://code.jquery.com/ui/1.11.1/themes/mint-choc/jquery-ui.css" rel="stylesheet"/>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.0/jquery.min.js"></script>
<script src="https://code.jquery.com/ui/1.11.1/jquery-ui.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.2.0/knockout-min.js"></script>
<script src="http://momentjs.com/downloads/moment.js"></script>
<main id="form-section" class="main">
  
 <h1>Welcome to an Example</h1>
 <form enctype="multipart/form-data" method="post" id="input-form">
  <h2>Validation Form</h2>
  <div class="form-liner">
   <p>Welcome, please fill out the form completely before continuing on to the next step.</p>
   <p>Fields marked with a <mark class="validation-inactive">*</mark> are required.</p>
   <section class="inner-liner">
    <span class="form-item">
     <label for="_firstName">First Name:<mark data-bind="css: FirstName.ValidationState.MessageClass()" >*</mark></label>
     <input type="text" name="firstName" id="_firstName" tabindex="1" data-bind="textInput: FirstName, css: FirstName.ValidationState.IsValid() ? '' : 'item-error'" />
     <span class="validation-message validation-active" data-bind="text: FirstName.ValidationState.ValidationMessage"></span>
    </span>
    <span class="form-item">
     <label for="_lastName">Last Name:<mark data-bind="css: LastName.ValidationState.MessageClass()">*</mark></label>
     <input type="text" name="lastName" id="_lastName" tabindex="2" data-bind="textInput: LastName, css: LastName.ValidationState.IsValid() ? '' : 'item-error'" />
     <span class="validation-message validation-active" data-bind="text: LastName.ValidationState.ValidationMessage"></span>
    </span>
    <span class="form-item">
        <label>Gender:</label>
        <span class="radio-list vertical">
        <!-- ko template: {name: 'gender-option-template', foreach: AvailableGenders, as: 'item'} -->
        <!-- /ko -->
        </span>
    </span>
    <span class="form-item">
        <label for="_birthDate">Birth Date:</label>
        <input type="text" class="datepicker" name="birthDate" id="_birthDate" tabindex="6" data-bind="textInput: BirthDate" />
        <span class="validation-message validation-active"></span>
    </span>
   </section>
   <span class="action-wrapper">
    <input type="submit" value="Submit" name="Submit" id="_submit" />
    <input type="reset" value="Clear" name="Clear" id="_clear" />
   </span>
  </div>
 </form>
    <section class="bio modal">
        <h2>Your Bio Preview</h2>
        <div class="content">
            <div data-bind="visible: IsValid(), html: UserBio"></div>
            <div data-bind="visible: !IsValid()">No data available</div>
        </div>
    </section>
</main>
<script type="text/html" name="gender-option" id="gender-option-template">
    <span class="input-option">
    <input type="radio" name="gender" data-bind="attr: {'id': ('_gender' + item), 'tabindex': (3 + item)}, value: item, checked: $parent.Gender" />
        <label data-bind="attr: {'for': ('_gender' + item)}, text: GenderOptions.GetName(item)"></label>
    </span>
    <br />
</script>
&#13;
&#13;
&#13;

有没有更好的方法来实现这一目标,或者如果我在正确的轨道上,我如何确定需要哪些可观察的属性?

其他详细信息

我正在使用以下(相关)技术

  • Knockout JS(3.2.0)
  • JQuery(2.1.0)
  • HTML 5
  • JQuery UI(1.11.1)
  • MomentJS

1 个答案:

答案 0 :(得分:1)

您在扩展程序中添加到target的所有属性都可以直接在可观察对象上访问。所以你可以例如添加一个布尔标志并在稍后查询它的存在:

ko.extenders.required = function (target, data) {
    target.validatesAsRequired = true;

    // ...

    //return the original observable
    return target;
};

以后

var members = [];
for (var item in self) {
    //attempt to identify the observables, which works
    if(!ko.isComputed(self[item]) && ko.isObservable(self[item])) {
        // try to detect required extensions
        if(self[item].validatesAsRequired) {
            members.push({name: item, type: typeof self[item], instance: self[item]});
        }
    }
}
console.log(members);
return members;