diff --git a/libs/horizon/src/dom/validators/PropertiesData.ts b/libs/horizon/src/dom/validators/PropertiesData.ts new file mode 100644 index 00000000..c11e5fdf --- /dev/null +++ b/libs/horizon/src/dom/validators/PropertiesData.ts @@ -0,0 +1,125 @@ +/* eslint-disable no-sparse-arrays */ + +// 属性值的数据类型 +export enum PROPERTY_TYPE { + BOOLEAN, // 普通布尔类型 + STRING, // 普通的字符串类型 + SPECIAL, // 需要特殊处理的属性类型 + BOOLEAN_STR, // 字符串类型的 true false +}; + +export type PropDetails = { + propName: string, + type: PROPERTY_TYPE, + attrName: string, + attrNS: string | null, +}; + +export function getPropDetails(name: string): PropDetails | null { + return propsDetailData[name] || null; +} + +// 属性相关数据 +// 依次为 propertyName、type、attributeName、attributeNamespace,不填则使用默认值 +// type 默认 STRING +// attributeName 默认与 propertyName 相同 +// attributeNamespace 默认 null +const propertiesData = [ + // 一些特殊属性 + ['children', PROPERTY_TYPE.SPECIAL], + ['dangerouslySetInnerHTML', PROPERTY_TYPE.SPECIAL], + ['defaultValue', PROPERTY_TYPE.SPECIAL], + ['defaultChecked', PROPERTY_TYPE.SPECIAL], + ['innerHTML', PROPERTY_TYPE.SPECIAL], + ['style', PROPERTY_TYPE.SPECIAL], + + // propertyName 和 attributeName 不一样 + ['acceptCharset', , 'accept-charset'], + ['className', , 'class'], + ['htmlFor', , 'for'], + ['httpEquiv', , 'http-equiv'], + // 字符串类型的 true false + ['contentEditable', PROPERTY_TYPE.BOOLEAN_STR, 'contenteditable'], + ['spellCheck', PROPERTY_TYPE.BOOLEAN_STR, 'spellcheck'], + ['draggable', PROPERTY_TYPE.BOOLEAN_STR], + ['value', PROPERTY_TYPE.BOOLEAN_STR], + // SVG 相关,字符串类型的 true false + ['autoReverse', PROPERTY_TYPE.BOOLEAN_STR], + ['externalResourcesRequired', PROPERTY_TYPE.BOOLEAN_STR], + ['focusable', PROPERTY_TYPE.BOOLEAN_STR], + ['preserveAlpha', PROPERTY_TYPE.BOOLEAN_STR], + // 布尔类型 + ['allowFullScreen', PROPERTY_TYPE.BOOLEAN, 'allowfullscreen'], + ['async', PROPERTY_TYPE.BOOLEAN], + ['autoFocus', PROPERTY_TYPE.BOOLEAN, 'autofocus'], + ['autoPlay', PROPERTY_TYPE.BOOLEAN, 'autoplay'], + ['controls', PROPERTY_TYPE.BOOLEAN], + ['default', PROPERTY_TYPE.BOOLEAN], + ['defer', PROPERTY_TYPE.BOOLEAN], + ['disabled', PROPERTY_TYPE.BOOLEAN], + ['disablePictureInPicture', PROPERTY_TYPE.BOOLEAN, 'disablepictureinpicture'], + ['disableRemotePlayback', PROPERTY_TYPE.BOOLEAN, 'disableremoteplayback'], + ['formNoValidate', PROPERTY_TYPE.BOOLEAN, 'formnovalidate'], + ['hidden', PROPERTY_TYPE.BOOLEAN], + ['loop', PROPERTY_TYPE.BOOLEAN], + ['noModule', PROPERTY_TYPE.BOOLEAN, 'nomodule'], + ['noValidate', PROPERTY_TYPE.BOOLEAN, 'novalidate'], + ['open', PROPERTY_TYPE.BOOLEAN], + ['playsInline', PROPERTY_TYPE.BOOLEAN, 'playsinline'], + ['readOnly', PROPERTY_TYPE.BOOLEAN, 'readonly'], + ['required', PROPERTY_TYPE.BOOLEAN], + ['reversed', PROPERTY_TYPE.BOOLEAN], + ['scoped', PROPERTY_TYPE.BOOLEAN], + ['seamless', PROPERTY_TYPE.BOOLEAN], + ['itemScope', PROPERTY_TYPE.BOOLEAN, 'itemscope'], + // 框架需要当做 property 来处理的,而不是 attribute 来处理的属性 + ['checked', PROPERTY_TYPE.BOOLEAN], + ['multiple', PROPERTY_TYPE.BOOLEAN], + ['muted', PROPERTY_TYPE.BOOLEAN], + ['selected', PROPERTY_TYPE.BOOLEAN], + + // SVG 属性 + // xlink namespace 的 SVG 属性 + ['xlinkActuate', , 'xlink:actuate', 'http://www.w3.org/1999/xlink'], + ['xlinkArcrole', , 'xlink:arcrole', 'http://www.w3.org/1999/xlink'], + ['xlinkRole', , 'xlink:role', 'http://www.w3.org/1999/xlink'], + ['xlinkShow', , 'xlink:show', 'http://www.w3.org/1999/xlink'], + ['xlinkTitle', , 'xlink:title', 'http://www.w3.org/1999/xlink'], + ['xlinkType', , 'xlink:type', 'http://www.w3.org/1999/xlink'], + // xml namespace 的 SVG 属性 + ['xmlBase', , 'xml:base', 'http://www.w3.org/XML/1998/namespace'], + ['xmlLang', , 'xml:lang', 'http://www.w3.org/XML/1998/namespace'], + ['xmlSpace', , 'xml:space', 'http://www.w3.org/XML/1998/namespace'], + // HTML and SVG 中都有的属性,大小写敏感 + ['tabIndex', , 'tabindex'], + ['crossOrigin', , 'crossorigin'], + // 接受 URL 的属性 + ['xlinkHref', , 'xlink:href', 'http://www.w3.org/1999/xlink'], + ['formAction', , 'formaction'], +]; + +const propsDetailData = {}; + +propertiesData.forEach(record => { + const propName = record[0]; + let [type, attrName, attrNS] = record.slice(1); + + if (type === undefined) { + type = PROPERTY_TYPE.STRING; + } + + if (!attrName) { + attrName = propName; + } + + if (!attrNS) { + attrNS = null; + } + + propsDetailData[propName] = { + propName, + type, + attrName, + attrNS, + }; +}); diff --git a/libs/horizon/src/dom/validators/ValidateProps.ts b/libs/horizon/src/dom/validators/ValidateProps.ts new file mode 100644 index 00000000..d04aec3e --- /dev/null +++ b/libs/horizon/src/dom/validators/ValidateProps.ts @@ -0,0 +1,117 @@ +import { + getPropDetails, PROPERTY_TYPE, PropDetails, +} from './PropertiesData'; + +const INVALID_EVENT_NAME_REGEX = /^on[^A-Z]/; + + +// 是内置元素 +export function isNativeElement(tagName: string, props: Object) { + return !tagName.includes('-') && props.is === undefined; +} + +function isInvalidBoolean( + attributeName: string, + value: any, + propDetails: PropDetails, +): boolean { + if (propDetails.type === PROPERTY_TYPE.SPECIAL) { + return false; + } + + // 布尔值校验 + if (typeof value === 'boolean') { + const isBooleanType = propDetails.type === PROPERTY_TYPE.BOOLEAN_STR || propDetails.type === PROPERTY_TYPE.BOOLEAN; + + if (isBooleanType || (attributeName.startsWith('data-') && attributeName.startsWith('aria-'))) { + return false; + } + + // 否则有问题 + return true; + } + + return false; +} + +function isValidProp(tagName, name, value) { + // 校验事件名称 + if (isEventProp(name)) { + // 事件名称不满足小驼峰 + if (INVALID_EVENT_NAME_REGEX.test(name)) { + console.error('Invalid event property `%s`, events use the camelCase name.', name); + } + return true; + } + + const propDetails = getPropDetails(name); + + // 当已知属性为错误类型时发出警告 + if (propDetails !== null && isInvalidBoolean(name, value, propDetails)) { + return false; + } + + return true; +} + +export function isInvalidValue( + name: string, + value: any, + propDetails: PropDetails | null, + isNativeTag: boolean, +): boolean { + if (value == null) { + return true; + } + + if (!isNativeTag) { + return false; + } + + if (propDetails !== null && isInvalidBoolean(name, value, propDetails)) { + return true; + } + + if (propDetails !== null && propDetails.type === PROPERTY_TYPE.BOOLEAN) { + return !value; + } + + return false; +} + +// 是事件属性 +export function isEventProp(propName) { + return propName.substr(0, 2) === 'on'; +} + +// dev模式下校验属性是否合法 +export function validateProps(type, props) { + if (!props) { + return; + } + + // 非内置的变迁 + if (!isNativeElement(type, props)) { + return; + } + + // style属性必须是对象 + if (props.style != null && typeof props.style !== 'object') { + throw new Error('style should be a object.'); + } + + if (__DEV__) { + // 校验属性 + const invalidProps = Object.keys(props).filter(key => !isValidProp(type, key, props[key])); + + const propString = invalidProps.map(prop => '`' + prop + '`').join(', '); + + if (invalidProps.length >= 1) { + console.error( + 'Invalid value for prop %s on <%s> tag.', + propString, + type, + ); + } + } +} diff --git a/libs/horizon/src/dom/valueHandler/InputValueHandler.ts b/libs/horizon/src/dom/valueHandler/InputValueHandler.ts new file mode 100644 index 00000000..5632085f --- /dev/null +++ b/libs/horizon/src/dom/valueHandler/InputValueHandler.ts @@ -0,0 +1,96 @@ +import {updateCommonProp} from '../DOMPropertiesHandler/UpdateCommonProp'; +import {getVNodeProps} from '../DOMInternalKeys'; +import {IProperty} from '../utils/Interface'; +import {getRootElement} from '../utils/Common'; +import {isInputValueChanged} from './ValueChangeHandler'; + +function getInitValue(dom: HTMLInputElement, properties: IProperty) { + const {value, defaultValue, checked, defaultChecked} = properties; + + const defaultValueStr = defaultValue != null ? defaultValue : ''; + const initValue = value != null ? value : defaultValueStr; + const initChecked = checked != null ? checked : defaultChecked; + + return {initValue, initChecked}; +} + +export function getInputPropsWithoutValue(dom: HTMLInputElement, properties: IProperty) { + // checked属于必填属性,无法置空 + let {checked} = properties; + if (checked == null) { + checked = getInitValue(dom, properties).initChecked; + } + + return { + ...properties, + value: undefined, + defaultValue: undefined, + defaultChecked: undefined, + checked, + }; +} + +export function updateInputValue(dom: HTMLInputElement, properties: IProperty) { + const {value, checked} = properties; + + if (checked != null) { + updateCommonProp(dom, 'checked', checked); + } else if (value != null) { // 处理 dom.value 逻辑 + if (dom.value !== String(value)) { + dom.value = String(value); + } + } +} + +// 设置input的初始值 +export function setInitInputValue(dom: HTMLInputElement, properties: IProperty) { + const {value, defaultValue} = properties; + const {initValue, initChecked} = getInitValue(dom, properties); + + if (value != null || defaultValue != null) { + // value 的使用优先级 value 属性 > defaultValue 属性 > 空字符串 + const initValueStr = String(initValue); + + dom.value = initValueStr; + + dom.defaultValue = initValueStr; + } + + // checked 的使用优先级 checked 属性 > defaultChecked 属性 > false + dom.defaultChecked = Boolean(initChecked); +} + +export function resetInputValue(dom: HTMLInputElement, properties: IProperty) { + const {name, type} = properties; + // 如果是 radio,先更新相同 name 的 radio + if (type === 'radio' && name != null) { + // radio 的根节点 + const radioRoot = getRootElement(dom); + + const radioList = radioRoot.querySelectorAll(`input[type="radio"]`); + + for (let i = 0; i < radioList.length; i++) { + const radio = radioList[i]; + // @ts-ignore + if (radio.name !== name) { + continue; + } + if (radio === dom) { + continue; + } + // @ts-ignore + if (radio.form !== dom.form) { + continue; + } + + // @ts-ignore + const nonHorizonRadioProps = getVNodeProps(radio); + + isInputValueChanged(radio); + // @ts-ignore + updateInputValue(radio, nonHorizonRadioProps); + } + } else { + updateInputValue(dom, properties); + } +} diff --git a/libs/horizon/src/dom/valueHandler/OptionValueHandler.ts b/libs/horizon/src/dom/valueHandler/OptionValueHandler.ts new file mode 100644 index 00000000..bd487d4e --- /dev/null +++ b/libs/horizon/src/dom/valueHandler/OptionValueHandler.ts @@ -0,0 +1,21 @@ +import * as Horizon from '../../external/Horizon'; +import {IProperty} from '../utils/Interface'; + +// 把 const a = 'a'; 转成 giraffe +function concatChildren(children) { + let content = ''; + Horizon.Children.forEach(children, function(child) { + content += child; + }); + + return content; +} + +export function getOptionPropsWithoutValue(dom: Element, properties: IProperty) { + const content = concatChildren(properties.children); + + return { + ...properties, + children: content || undefined, // 覆盖children + }; +} diff --git a/libs/horizon/src/dom/valueHandler/SelectValueHandler.ts b/libs/horizon/src/dom/valueHandler/SelectValueHandler.ts new file mode 100644 index 00000000..6602e876 --- /dev/null +++ b/libs/horizon/src/dom/valueHandler/SelectValueHandler.ts @@ -0,0 +1,70 @@ +import {HorizonSelect, IProperty} from '../utils/Interface'; + +// 更新