vModel.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. import {
  2. type DirectiveBinding,
  3. type DirectiveHook,
  4. type ObjectDirective,
  5. type VNode,
  6. nextTick,
  7. warn,
  8. } from '@vue/runtime-core'
  9. import { addEventListener } from '../modules/events'
  10. import {
  11. invokeArrayFns,
  12. isArray,
  13. isSet,
  14. looseEqual,
  15. looseIndexOf,
  16. looseToNumber,
  17. } from '@vue/shared'
  18. type AssignerFn = (value: any) => void
  19. const getModelAssigner = (vnode: VNode): AssignerFn => {
  20. const fn =
  21. vnode.props!['onUpdate:modelValue'] ||
  22. (__COMPAT__ && vnode.props!['onModelCompat:input'])
  23. return isArray(fn) ? value => invokeArrayFns(fn, value) : fn
  24. }
  25. function onCompositionStart(e: Event) {
  26. ;(e.target as any).composing = true
  27. }
  28. function onCompositionEnd(e: Event) {
  29. const target = e.target as any
  30. if (target.composing) {
  31. target.composing = false
  32. target.dispatchEvent(new Event('input'))
  33. }
  34. }
  35. const assignKey = Symbol('_assign')
  36. type ModelDirective<T> = ObjectDirective<
  37. T & { [assignKey]: AssignerFn; _assigning?: boolean }
  38. >
  39. // We are exporting the v-model runtime directly as vnode hooks so that it can
  40. // be tree-shaken in case v-model is never used.
  41. export const vModelText: ModelDirective<
  42. HTMLInputElement | HTMLTextAreaElement
  43. > = {
  44. created(el, { modifiers: { lazy, trim, number } }, vnode) {
  45. el[assignKey] = getModelAssigner(vnode)
  46. const castToNumber =
  47. number || (vnode.props && vnode.props.type === 'number')
  48. addEventListener(el, lazy ? 'change' : 'input', e => {
  49. if ((e.target as any).composing) return
  50. let domValue: string | number = el.value
  51. if (trim) {
  52. domValue = domValue.trim()
  53. }
  54. if (castToNumber) {
  55. domValue = looseToNumber(domValue)
  56. }
  57. el[assignKey](domValue)
  58. })
  59. if (trim) {
  60. addEventListener(el, 'change', () => {
  61. el.value = el.value.trim()
  62. })
  63. }
  64. if (!lazy) {
  65. addEventListener(el, 'compositionstart', onCompositionStart)
  66. addEventListener(el, 'compositionend', onCompositionEnd)
  67. // Safari < 10.2 & UIWebView doesn't fire compositionend when
  68. // switching focus before confirming composition choice
  69. // this also fixes the issue where some browsers e.g. iOS Chrome
  70. // fires "change" instead of "input" on autocomplete.
  71. addEventListener(el, 'change', onCompositionEnd)
  72. }
  73. },
  74. // set value on mounted so it's after min/max for type="range"
  75. mounted(el, { value }) {
  76. el.value = value == null ? '' : value
  77. },
  78. beforeUpdate(el, { value, modifiers: { lazy, trim, number } }, vnode) {
  79. el[assignKey] = getModelAssigner(vnode)
  80. // avoid clearing unresolved text. #2302
  81. if ((el as any).composing) return
  82. const elValue =
  83. number || el.type === 'number' ? looseToNumber(el.value) : el.value
  84. const newValue = value == null ? '' : value
  85. if (elValue === newValue) {
  86. return
  87. }
  88. if (document.activeElement === el && el.type !== 'range') {
  89. if (lazy) {
  90. return
  91. }
  92. if (trim && el.value.trim() === newValue) {
  93. return
  94. }
  95. }
  96. el.value = newValue
  97. },
  98. }
  99. export const vModelCheckbox: ModelDirective<HTMLInputElement> = {
  100. // #4096 array checkboxes need to be deep traversed
  101. deep: true,
  102. created(el, _, vnode) {
  103. el[assignKey] = getModelAssigner(vnode)
  104. addEventListener(el, 'change', () => {
  105. const modelValue = (el as any)._modelValue
  106. const elementValue = getValue(el)
  107. const checked = el.checked
  108. const assign = el[assignKey]
  109. if (isArray(modelValue)) {
  110. const index = looseIndexOf(modelValue, elementValue)
  111. const found = index !== -1
  112. if (checked && !found) {
  113. assign(modelValue.concat(elementValue))
  114. } else if (!checked && found) {
  115. const filtered = [...modelValue]
  116. filtered.splice(index, 1)
  117. assign(filtered)
  118. }
  119. } else if (isSet(modelValue)) {
  120. const cloned = new Set(modelValue)
  121. if (checked) {
  122. cloned.add(elementValue)
  123. } else {
  124. cloned.delete(elementValue)
  125. }
  126. assign(cloned)
  127. } else {
  128. assign(getCheckboxValue(el, checked))
  129. }
  130. })
  131. },
  132. // set initial checked on mount to wait for true-value/false-value
  133. mounted: setChecked,
  134. beforeUpdate(el, binding, vnode) {
  135. el[assignKey] = getModelAssigner(vnode)
  136. setChecked(el, binding, vnode)
  137. },
  138. }
  139. function setChecked(
  140. el: HTMLInputElement,
  141. { value, oldValue }: DirectiveBinding,
  142. vnode: VNode,
  143. ) {
  144. // store the v-model value on the element so it can be accessed by the
  145. // change listener.
  146. ;(el as any)._modelValue = value
  147. if (isArray(value)) {
  148. el.checked = looseIndexOf(value, vnode.props!.value) > -1
  149. } else if (isSet(value)) {
  150. el.checked = value.has(vnode.props!.value)
  151. } else if (value !== oldValue) {
  152. el.checked = looseEqual(value, getCheckboxValue(el, true))
  153. }
  154. }
  155. export const vModelRadio: ModelDirective<HTMLInputElement> = {
  156. created(el, { value }, vnode) {
  157. el.checked = looseEqual(value, vnode.props!.value)
  158. el[assignKey] = getModelAssigner(vnode)
  159. addEventListener(el, 'change', () => {
  160. el[assignKey](getValue(el))
  161. })
  162. },
  163. beforeUpdate(el, { value, oldValue }, vnode) {
  164. el[assignKey] = getModelAssigner(vnode)
  165. if (value !== oldValue) {
  166. el.checked = looseEqual(value, vnode.props!.value)
  167. }
  168. },
  169. }
  170. export const vModelSelect: ModelDirective<HTMLSelectElement> = {
  171. // <select multiple> value need to be deep traversed
  172. deep: true,
  173. created(el, { value, modifiers: { number } }, vnode) {
  174. const isSetModel = isSet(value)
  175. addEventListener(el, 'change', () => {
  176. const selectedVal = Array.prototype.filter
  177. .call(el.options, (o: HTMLOptionElement) => o.selected)
  178. .map((o: HTMLOptionElement) =>
  179. number ? looseToNumber(getValue(o)) : getValue(o),
  180. )
  181. el[assignKey](
  182. el.multiple
  183. ? isSetModel
  184. ? new Set(selectedVal)
  185. : selectedVal
  186. : selectedVal[0],
  187. )
  188. el._assigning = true
  189. nextTick(() => {
  190. el._assigning = false
  191. })
  192. })
  193. el[assignKey] = getModelAssigner(vnode)
  194. },
  195. // set value in mounted & updated because <select> relies on its children
  196. // <option>s.
  197. mounted(el, { value, modifiers: { number } }) {
  198. setSelected(el, value, number)
  199. },
  200. beforeUpdate(el, _binding, vnode) {
  201. el[assignKey] = getModelAssigner(vnode)
  202. },
  203. updated(el, { value, modifiers: { number } }) {
  204. if (!el._assigning) {
  205. setSelected(el, value, number)
  206. }
  207. },
  208. }
  209. function setSelected(el: HTMLSelectElement, value: any, number: boolean) {
  210. const isMultiple = el.multiple
  211. const isArrayValue = isArray(value)
  212. if (isMultiple && !isArrayValue && !isSet(value)) {
  213. __DEV__ &&
  214. warn(
  215. `<select multiple v-model> expects an Array or Set value for its binding, ` +
  216. `but got ${Object.prototype.toString.call(value).slice(8, -1)}.`,
  217. )
  218. return
  219. }
  220. for (let i = 0, l = el.options.length; i < l; i++) {
  221. const option = el.options[i]
  222. const optionValue = getValue(option)
  223. if (isMultiple) {
  224. if (isArrayValue) {
  225. const optionType = typeof optionValue
  226. // fast path for string / number values
  227. if (optionType === 'string' || optionType === 'number') {
  228. option.selected = value.includes(
  229. number ? looseToNumber(optionValue) : optionValue,
  230. )
  231. } else {
  232. option.selected = looseIndexOf(value, optionValue) > -1
  233. }
  234. } else {
  235. option.selected = value.has(optionValue)
  236. }
  237. } else if (looseEqual(getValue(option), value)) {
  238. if (el.selectedIndex !== i) el.selectedIndex = i
  239. return
  240. }
  241. }
  242. if (!isMultiple && el.selectedIndex !== -1) {
  243. el.selectedIndex = -1
  244. }
  245. }
  246. // retrieve raw value set via :value bindings
  247. function getValue(el: HTMLOptionElement | HTMLInputElement) {
  248. return '_value' in el ? (el as any)._value : el.value
  249. }
  250. // retrieve raw value for true-value and false-value set via :true-value or :false-value bindings
  251. function getCheckboxValue(
  252. el: HTMLInputElement & { _trueValue?: any; _falseValue?: any },
  253. checked: boolean,
  254. ) {
  255. const key = checked ? '_trueValue' : '_falseValue'
  256. return key in el ? el[key] : checked
  257. }
  258. export const vModelDynamic: ObjectDirective<
  259. HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
  260. > = {
  261. created(el, binding, vnode) {
  262. callModelHook(el, binding, vnode, null, 'created')
  263. },
  264. mounted(el, binding, vnode) {
  265. callModelHook(el, binding, vnode, null, 'mounted')
  266. },
  267. beforeUpdate(el, binding, vnode, prevVNode) {
  268. callModelHook(el, binding, vnode, prevVNode, 'beforeUpdate')
  269. },
  270. updated(el, binding, vnode, prevVNode) {
  271. callModelHook(el, binding, vnode, prevVNode, 'updated')
  272. },
  273. }
  274. function resolveDynamicModel(tagName: string, type: string | undefined) {
  275. switch (tagName) {
  276. case 'SELECT':
  277. return vModelSelect
  278. case 'TEXTAREA':
  279. return vModelText
  280. default:
  281. switch (type) {
  282. case 'checkbox':
  283. return vModelCheckbox
  284. case 'radio':
  285. return vModelRadio
  286. default:
  287. return vModelText
  288. }
  289. }
  290. }
  291. function callModelHook(
  292. el: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
  293. binding: DirectiveBinding,
  294. vnode: VNode,
  295. prevVNode: VNode | null,
  296. hook: keyof ObjectDirective,
  297. ) {
  298. const modelToUse = resolveDynamicModel(
  299. el.tagName,
  300. vnode.props && vnode.props.type,
  301. )
  302. const fn = modelToUse[hook] as DirectiveHook
  303. fn && fn(el, binding, vnode, prevVNode)
  304. }
  305. // SSR vnode transforms, only used when user includes client-oriented render
  306. // function in SSR
  307. export function initVModelForSSR() {
  308. vModelText.getSSRProps = ({ value }) => ({ value })
  309. vModelRadio.getSSRProps = ({ value }, vnode) => {
  310. if (vnode.props && looseEqual(vnode.props.value, value)) {
  311. return { checked: true }
  312. }
  313. }
  314. vModelCheckbox.getSSRProps = ({ value }, vnode) => {
  315. if (isArray(value)) {
  316. if (vnode.props && looseIndexOf(value, vnode.props.value) > -1) {
  317. return { checked: true }
  318. }
  319. } else if (isSet(value)) {
  320. if (vnode.props && value.has(vnode.props.value)) {
  321. return { checked: true }
  322. }
  323. } else if (value) {
  324. return { checked: true }
  325. }
  326. }
  327. vModelDynamic.getSSRProps = (binding, vnode) => {
  328. if (typeof vnode.type !== 'string') {
  329. return
  330. }
  331. const modelToUse = resolveDynamicModel(
  332. // resolveDynamicModel expects an uppercase tag name, but vnode.type is lowercase
  333. vnode.type.toUpperCase(),
  334. vnode.props && vnode.props.type,
  335. )
  336. if (modelToUse.getSSRProps) {
  337. return modelToUse.getSSRProps(binding, vnode)
  338. }
  339. }
  340. }