主頁 > 前端設計 > vue雙向資料系結 之 資料劫持

vue雙向資料系結 之 資料劫持

2020-09-20 19:38:52 前端設計

今天聊一個稍微有點兒小眾的話題:資料劫持,也叫資料代理。

所謂資料劫持,指的是在訪問或者修改物件的某個屬性時,通過一段代碼攔截這個行為,進行額外的操作或者修改回傳結果。比較典型的是 Object.defineProperty() 和 ES2015 中新增的 Proxy 物件。另外還有已經被廢棄的 Object.observe(),廢棄的原因正是 Proxy 的出現,因此這里我們就不繼續討論這個已經被瀏覽器洗掉的方法了。

資料劫持最著名的應用當屬雙向系結,這也是一個已經被討論爛了的面試必考題。例如 Vue 2.x 使用的是 Object.defineProperty()(Vue 在 3.x 版本之后改用 Proxy 進行實作)。此外 immer.js 為了保證資料的 immutable 屬性,使用了 Proxy 來阻斷常規的修改操作,也是資料劫持的一種應用。

我們來分別看看這兩種方法的優劣。

Object.defineProperty
Vue 的雙向系結已經升級為前端面試的必考題,原理我就不再重復了,網上一大片。簡單來說就是利用 Object.defineProperty(),并且把內部解耦為 Observer, Dep, 并使用 Watcher 相連。

Object.defineProperty() 的問題主要有三個:

不能監聽陣列的變化
看如下代碼:

let arr = [1,2,3]
let obj = {}

Object.defineProperty(obj, 'arr', {
  get () {
    console.log('get arr')
    return arr
  },
  set (newVal) {
    console.log('set', newVal)
    arr = newVal
  }
})

obj.arr.push(4) // 只會列印 get arr, 不會列印 set
obj.arr = [1,2,3,4] // 這個能正常 set
陣列的以下幾個方法不會觸發 set:

push
pop
shift
unshift
splice
sort
reverse
Vue 把這些方法定義為變異方法 (mutation method),指的是會修改原來陣列的方法。與之對應則是非變異方法 (non-mutating method),例如 filter, concat, slice 等,它們都不會修改原始陣列,而會回傳一個新的陣列。Vue 官網有相關檔案講述這個問題。

Vue 的做法是把這些方法重寫來實作陣列的劫持。一個極簡的實作如下:

const aryMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
const arrayAugmentations = [];

aryMethods.forEach((method)=> {

  // 這里是原生 Array 的原型方法
  let original = Array.prototype[method];

  // 將 push, pop 等封裝好的方法定義在物件 arrayAugmentations 的屬性上
  // 注意:是實體屬性而非原型屬性
  arrayAugmentations[method] = function () {
    console.log('我被改變啦!');

    // 呼叫對應的原生方法并回傳結果
    return original.apply(this, arguments);
  };

});

let list = ['a', 'b', 'c'];
// 將我們要監聽的陣列的原型指標指向上面定義的空陣列物件
// 這樣就能在呼叫 push, pop 這些方法時走進我們剛定義的方法,多了一句 console.log
list.__proto__ = arrayAugmentations;
list.push('d');  // 我被改變啦!

// 這個 list2 是個普通的陣列,所以呼叫 push 不會走到我們的方法里面。
let list2 = ['a', 'b', 'c'];
list2.push('d');  // 不輸出內容
必須遍歷物件的每個屬性
使用 Object.defineProperty() 多數要配合 Object.keys() 和遍歷,于是多了一層嵌套。如:

Object.keys(obj).forEach(key => {
  Object.defineProperty(obj, key, {
    // ...
  })
})
必須深層遍歷嵌套的物件
所謂的嵌套物件,是指類似

let obj = {
  info: {
    name: 'eason'
  }
}
如果是這一類嵌套物件,那就必須逐層遍歷,直到把每個物件的每個屬性都呼叫 Object.defineProperty() 為止。 Vue 的原始碼中就能找到這樣的邏輯 (叫做 walk 方法)。

Proxy
Proxy 在 ES2015 規范中被正式加入,它的支持度雖然不如 Object.defineProperty(),但其實也基本支持了 (除了 IE 和 Opera Mini 等少數瀏覽器,資料來自 caniuse),所以使用起來問題也不太大。

針對物件
在資料劫持這個問題上,Proxy 可以被認為是 Object.defineProperty() 的升級版。外界對某個物件的訪問,都必須經過這層攔截。因此它是針對 整個物件,而不是 物件的某個屬性,所以也就不需要對 keys 進行遍歷。這解決了上述 Object.defineProperty() 的第二個問題。

let obj = {
  name: 'Eason',
  age: 30
}

let handler = {
  get (target, key, receiver) {
    console.log('get', key)
    return Reflect.get(target, key, receiver)
  },
  set (target, key, value, receiver) {
    console.log('set', key, value)
    return Reflect.set(target, key, value, receiver)
  }
}
let proxy = new Proxy(obj, handler)

proxy.name = 'Zoe' // set name Zoe
proxy.age = 18 // set age 18
如上代碼,Proxy 是針對 obj 的。因此無論 obj 內部包含多少個 key ,都可以走進 set。(省了一個 Object.keys() 的遍歷)

另外這個 Reflect.get 和 Reflect.set 可以理解為類繼承里的 super,即呼叫原來的方法。詳細的 Reflect 可以查看這里,本文不作展開。

支持陣列
let arr = [1,2,3]

let proxy = new Proxy(arr, {
    get (target, key, receiver) {
        console.log('get', key)
        return Reflect.get(target, key, receiver)
    },
    set (target, key, value, receiver) {
        console.log('set', key, value)
        return Reflect.set(target, key, value, receiver)
    }
})

proxy.push(4)
// 能夠列印出很多內容
// get push     (尋找 proxy.push 方法)
// get length   (獲取當前的 length)
// set 3 4      (設定 proxy[3] = 4)
// set length 4 (設定 proxy.length = 4)
Proxy 不需要對陣列的方法進行多載,省去了眾多 hack,減少代碼量等于減少了維護成本,而且標準的就是最好的。

嵌套支持
本質上,Proxy 也是不支持嵌套的,這點和 Object.defineProperty() 是一樣的。因此也需要通過逐層遍歷來解決。Proxy 的寫法是在 get 里面遞回呼叫 Proxy 并回傳,代碼如下:

let obj = {
  info: {
    name: 'eason',
    blogs: ['webpack', 'babel', 'cache']
  }
}

let handler = {
  get (target, key, receiver) {
    console.log('get', key)
    // 遞回創建并回傳
    if (typeof target[key] === 'object' && target[key] !== null) {
      return new Proxy(target[key], handler)
    }
    return Reflect.get(target, key, receiver)
  },
  set (target, key, value, receiver) {
    console.log('set', key, value)
    return Reflect.set(target, key, value, receiver)
  }
}
let proxy = new Proxy(obj, handler)

// 以下兩句都能夠進入 set
proxy.info.name = 'Zoe'
proxy.info.blogs.push('proxy')
其他區別
除了上述兩點之外,Proxy 還擁有以下優勢:

Proxy 的第二個引數可以有 13 種攔截方法,這比起 Object.defineProperty() 要更加豐富
Proxy 作為新標準受到瀏覽器廠商的重點關注和性能優化,相比之下 Object.defineProperty() 是一個已有的老方法。
這第二個優勢源于它是新標準。但新標準同樣也有劣勢,那就是:

Proxy 的兼容性不如 Object.defineProperty() (caniuse 的資料表明,QQ 瀏覽器和百度瀏覽器并不支持 Proxy,這對國內移動開發來說估計無法接受,但兩者都支持 Object.defineProperty())
不能使用 polyfill 來處理兼容性
這些比較僅針對“資料劫持的實作”這個需求而言。Object.defineProperty() 除了定義 get 和 set 之外,還能實作其他功能,因此即便不考慮兼容性的情況下,本文并不是想說一個可以完全淘汰另一個。

應用
只談技術本身而不談應用場景基本都是耍流氓。一個技術只有擁有了應用場景,才真正有價值。

如開頭所說,資料劫持多出現在框架內部,例如 Vue, immer 之類的,不過這些好像和我們普通程式員相去甚遠。除開這些,我列舉幾個可能的應用場景,大家在平時的作業中可能還能想到更多。

一道面試題
其實除了閱讀 Vue 的資料系結原始碼之外,我第二次了解這個技術是通過一道曾經在開發者群體中小火一陣的詭異題目:

什么樣的 a 可以滿足 (a === 1 && a === 2 && a === 3) === true 呢?(注意是 3 個 =,也就是嚴格相等)
既然是嚴格相等,型別轉換什么的基本不考慮了。一個自然的想法就是每次訪問 a 回傳的值都不一樣,那么肯定會想到資料劫持。(可能還有其他解法,但這里只講資料劫持的方法)

let current = 0
Object.defineProperty(window, 'a', {
  get () {
    current++
    return current
  }
})
console.log(a === 1 && a === 2 && a === 3) // true
使用 Proxy 也可以,但因為 Proxy 的語法是回傳一個新的物件,因此要做到 a === 1 可能比較困難,做到 obj.a === 1 還是 OK 的,反正原理是一樣的,也不必糾結太多。

多繼承
Javascript 通過原型鏈實作繼承,正常情況一個物件(或者類)只能繼承一個物件(或者類)。但通過這兩個方法都可以實作一種黑科技,允許一個物件繼承兩個物件。下面的例子使用 Proxy 實作。

let foo = {
  foo () {
    console.log('foo')
  }
}

let bar = {
  bar () {
    console.log('bar')
  }
}
// 正常狀態下,物件只能繼承一個物件,要么有 foo(),要么有 bar()
let sonOfFoo = Object.create(foo);
sonOfFoo.foo();     // foo
let sonOfBar = Object.create(bar);
sonOfBar.bar();     // bar

// 黑科技開始
let sonOfFooBar = new Proxy({}, {
  get (target, key) {
    return target[key] || foo[key] || bar[key];
  }
})
// 我們創造了一個物件同時繼承了兩個物件,foo() 和 bar() 同時擁有
sonOfFooBar.foo();   // foo 有foo方法,繼承自物件foo
sonOfFooBar.bar();   // bar 也有bar方法,繼承自物件bar
當然實際有啥用處我暫時還沒想到,且考慮到代碼的可讀性,多數可能只存在于炫技或者面試題中吧我猜……

隱藏私有變數
既然能夠操縱 get,自然就可以實作某些屬性可以訪問,而某些不可以,這就是共有和私有屬性的概念。實作起來也很簡單:

function getObject(rawObj, privateKeys) {
  return new Proxy(rawObj, {
    get (target, key, receiver) {
      if (privateKeys.indexOf(key) !== -1) {
        throw new ReferenceError(`${key} 是私有屬性,不能訪問。`)
      }

      return target[key]
    }
  })
}

let rawObj = {
  name: 'Zoe',
  age: 18,
  isFemale: true
}
let obj = getObject(rawObj, ['age'])

console.log(obj.name) // Zoe
console.log(obj.age) // 報錯
物件屬性的設定時校驗
如果物件的某些屬性有型別要求,只能接受特定型別的值,通過 Proxy 我們可以在設定時即給出錯誤,而不是在使用時再統一遞回遍歷檢查。這樣無論在執行效率還是在使用友好度上都更好一些。

let person = {
  name: 'Eason',
  age: 30
}

let handler = {
  set (target, key, value, receiver) {
    if (key === 'name' && typeof value !== 'string') {
      throw new Error('用戶姓名必須是字串型別')
    }
    if (key === 'age' && typeof value !== 'number') {
      throw new Error('用戶年齡必須是數字型別')
    }
    return Reflect.set(target, key, value, receiver)
  }
}

let personForUser = new Proxy(person, handler)

personForUser.name = 'Zoe' // OK
personForUser.age = '18' // 報錯
各類容錯檢查
我們常常會向后端發送請求,等待回應并處理回應的資料,且為了代碼健壯性,通常會有很多判斷,如:

// 發送請求代碼省略,總之獲取到了 response 物件了。
if (!response.data) {
  console.log('回應體沒有資訊')
  return
} else if (!response.data.message) {
  console.log('后端沒有回傳資訊')
  return
} else if (!response.data.message.from || !response.data.message.text) {
  console.log('后端回傳的資訊不完整')
  return
} else {
  console.log(`你收到了來自 ${response.data.message.from} 的資訊:${response.data.message.text}`)
}
代碼的實質是為了獲取 response.data.message.from 和 response.data.message.text,但需要逐層判斷,否則 JS 就會報錯。

我們可以考慮用 Proxy 來改造這段代碼,讓它稍微好看些。

// 故意設定一個錯誤的 data1,即 response.data = undefined
let response = {
  data1: {
    message: {
      from: 'Eason',
      text: 'Hello'
    }
  }
}

// 也可以根據 key 的不同給出更友好的提示
let dealError = key => console.log('Error key', key)

let isOK = obj => !obj['HAS_ERROR']

let handler = {
  get (target, key, receiver) {
    // 基本型別直接回傳
    if (target[key] !== undefined && typeof target[key] !== 'object') {
      return Reflect.get(target, key, receiver)
    }

    // 如果是 undefined,把訪問的的 key 傳遞到錯誤處理函式 dealError 里面
    if (!target[key]) {
      if (!target['HAS_ERROR']) {
        dealError(key)
      }
      return new Proxy({HAS_ERROR: true}, handler)
    }

    // 正常的話遞回創建 Proxy
    return new Proxy(target[key], handler)
  }
}

let resp = new Proxy(response, handler)

if (isOK(resp.data.message.text) && isOK(resp.data.message.from)) {
  console.log(`你收到了來自 ${response.data.message.from} 的資訊:${response.data.message.text}`)
}
因為我們故意設定了 response.data = undefined,因此會進入 dealError 方法,引數 key 的值為 data。

雖然從代碼量來看比上面的 if 檢查更長,但 isOK, handler 和 new Proxy 的定義都是可以復用的,可以移動到一個單獨的檔案,僅暴露幾個方法即可。所以實際的代碼只有 dealError 的定義和最后的一個 if 而已。

更多應用場景
設定物件默認值 - 創建一個物件,它的某些屬性自帶默認值。
優化的列舉型別 - 列舉型別的 key 出錯時立刻報錯而不是靜默的回傳 undefined,因代碼撰寫錯誤導致的重寫、洗掉等也可以被攔截。
追蹤物件和陣列的變化 - 在陣列和物件的某個元素/屬性發生變化時拋出事件。這可能適用于撤銷,重做,或者直接回到某個歷史狀態。
給物件的屬性訪問增加快取,提升速度 - 在對物件的某個屬性進行設定時記錄值,在訪問時直接回傳而不真的訪問屬性。增加 TTL 檢查機制(Time To Live,存活時間)防止記憶體泄露。
href="https://github.com/gergob/jsProxy/blob/master/06-array-in.js">支持 in 關鍵詞的陣列 - 通過設定 has 方法,內部呼叫 array.includes。使用的時候則直接 console.log('key' in someArr)。
實作單例模式 - 通過設定 construct 方法,在執行 new 運算子總是回傳同一個單例,從而實作單例模式。
Cookie 的型別轉換 - document.cookie 是一個用 ; 分割的字串。我們可以把它轉化為物件,并通過 Proxy 的 set 和 deleteProperty 重新定義設定和洗掉操作,用以對外暴露一個可操作的 Cookie 物件,方便使用。

轉載請註明出處,本文鏈接:https://www.uj5u.com/qianduan/91214.html

標籤:HTML5

上一篇:如何區別元素class里面的內容是bootstrap里面的樣式、還是自定義的class選擇器

下一篇:dreamweaver打開沒一會就彈出對檔案是否保存然后就強制退出 求解

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • vue移動端上拉加載

    可能做得過于簡單或者比較low,請各位大佬留情,一起探討技術 ......

    uj5u.com 2020-09-10 04:38:07 more
  • 優美網站首頁,頂部多層導航

    一個個人用的瀏覽器首頁,可以把一下常用的網站放在這里,平常打開會比較方便。 第一步,HTML代碼 <script src=https://www.cnblogs.com/szharf/p/"js/jquery-3.4.1.min.js"></script> <div id="navigate"> <ul> <li class="labels labels_1"> ......

    uj5u.com 2020-09-10 04:38:47 more
  • 頁面為要加<!DOCTYPE html>

    最近因為寫一個js函式,需要用到$(window).height(); 由于手寫demo的時候,過于自信,其實對前端方面的認識也不夠體系,用文本檔案直接敲出來的html代碼,第一行沒有加上<!DOCTYPE html> 導致了$(window).height();的結果直接是整個document的高 ......

    uj5u.com 2020-09-10 04:38:52 more
  • WordPress網站程式手動升級要做好資料備份

    WordPress博客網站程式在進行升級前,必須要做好網站資料的備份,這個問題良家佐言是遇見過的;在剛開始接觸WordPress博客程式的時候,因為升級問題和博客網站的修改的一些嘗試,良家佐言是吃盡了苦頭。因為購買的是西部數碼的空間和域名,每當佐言把自己的WordPress博客網站搞到一塌糊涂的時候 ......

    uj5u.com 2020-09-10 04:39:30 more
  • WordPress程式不能升級為5.4.2版本的原因

    WordPress是一款個人博客系統,受到英文博客愛好者和中文博客愛好者的追捧,并逐步演化成一款內容管理系統軟體;它是使用PHP語言和MySQL資料庫開發的,用戶可以在支持PHP和MySQL資料庫的服務器上使用自己的博客。每一次WordPress程式的更新,就會牽動無數WordPress愛好者的心, ......

    uj5u.com 2020-09-10 04:39:49 more
  • 使用CSS3的偽元素進行首字母下沉和首行改變樣式

    網頁中常見的一種效果,首字改變樣式或者首行改變樣式,效果如下圖。 代碼: <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, ......

    uj5u.com 2020-09-10 04:40:09 more
  • 關于a標簽的講解

    什么是a標簽? <a> 標簽定義超鏈接,用于從一個頁面鏈接到另一個頁面。 <a> 元素最重要的屬性是 href 屬性,它指定鏈接的目標。 a標簽的語法格式:<a href=https://www.cnblogs.com/summerxbc/p/"指定要跳轉的目標界面的鏈接">需要展示給用戶看見的內容</a> a標簽 在所有瀏覽器中,鏈接的默認外觀如下: 未被訪問的鏈接帶 ......

    uj5u.com 2020-09-10 04:40:11 more
  • 前端輪播圖

    在需要輪播的頁面是引入swiper.min.js和swiper.min.css swiper.min.js地址: 鏈接:https://pan.baidu.com/s/15Uh516YHa4CV3X-RyjEIWw 提取碼:4aks swiper.min.css地址 鏈接:https://pan.b ......

    uj5u.com 2020-09-10 04:40:13 more
  • 如何設定html中的背景圖片(全屏顯示,且不拉伸)

    1 <style>2 body{background-image:url(https://uploadbeta.com/api/pictures/random/?key=BingEverydayWallpaperPicture); 3 background-size:cover;background ......

    uj5u.com 2020-09-10 04:40:16 more
  • Java學習——HTML詳解(上)

    HTML詳解 初識HTML Hyper Text Markup Language(超文本標記語言) 1 <!--DOCTYPE:告訴瀏覽器我們要使用什么規范--> 2 <!DOCTYPE html> 3 <html lang="en"> 4 <head> 5 <!--meta 描述性的標簽,描述一些 ......

    uj5u.com 2020-09-10 04:40:33 more
最新发布
  • 我的第一個NPM包:panghu-planebattle-esm(胖虎飛機大戰)使用說明

    好家伙,我的包終于開發完啦 歡迎使用胖虎的飛機大戰包!! 為你的主頁添加色彩 這是一個有趣的網頁小游戲包,使用canvas和js開發 使用ES6模塊化開發 效果圖如下: (覺得圖片太sb的可以自己改) 代碼已開源!! Git: https://gitee.com/tang-and-han-dynas ......

    uj5u.com 2023-04-20 07:59:23 more
  • 生產事故-走近科學之消失的JWT

    入職多年,面對生產環境,盡管都是小心翼翼,慎之又慎,還是難免捅出簍子。輕則滿頭大汗,面紅耳赤。重則系統停擺,損失資金。每一個生產事故的背后,都是寶貴的經驗和教訓,都是專案成員的血淚史。為了更好地防范和遏制今后的各類事故,特開此專題,長期更新和記錄大大小小的各類事故。有些是親身經歷,有些是經人耳傳口授 ......

    uj5u.com 2023-04-18 07:55:04 more
  • 記錄--Canvas實作打飛字游戲

    這里給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 打開游戲界面,看到一個畫面簡潔、卻又富有挑戰性的游戲。螢屏上,有一個白色的矩形框,里面不斷下落著各種單詞,而我需要迅速地輸入這些單詞。如果我輸入的單詞與螢屏上的單詞匹配,那么我就可以獲得得分;如果我輸入的單詞錯誤或者時間過長,那么我就會輸 ......

    uj5u.com 2023-04-04 08:35:30 more
  • 了解 HTTP 看這一篇就夠

    在學習網路之前,了解它的歷史能夠幫助我們明白為何它會發展為如今這個樣子,引發探究網路的興趣。下面的這張圖片就展示了“互聯網”誕生至今的發展歷程。 ......

    uj5u.com 2023-03-16 11:00:15 more
  • 藍牙-低功耗中心設備

    //11.開啟藍牙配接器 openBluetoothAdapter //21.開始搜索藍牙設備 startBluetoothDevicesDiscovery //31.開啟監聽搜索藍牙設備 onBluetoothDeviceFound //30.停止監聽搜索藍牙設備 offBluetoothDevi ......

    uj5u.com 2023-03-15 09:06:45 more
  • canvas畫板(滑鼠和觸摸)

    <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>canves</title> <style> #canvas { cursor:url(../images/pen.png),crosshair; } #canvasdiv{ bo ......

    uj5u.com 2023-02-15 08:56:31 more
  • 手機端H5 實作自定義拍照界面

    手機端 H5 實作自定義拍照界面也可以使用 MediaDevices API 和 <video> 標簽來實作,和在桌面端做法基本一致。 首先,使用 MediaDevices.getUserMedia() 方法獲取攝像頭媒體流,并將其傳遞給 <video> 標簽進行渲染。 接著,使用 HTML 的 < ......

    uj5u.com 2023-01-12 07:58:22 more
  • 記錄--短視頻滑動播放在 H5 下的實作

    這里給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 短視頻已經無數不在了,但是主體還是使用 app 來承載的。本文講述 H5 如何實作 app 的視頻滑動體驗。 無聲勝有聲,一圖頂百辯,且看下圖: 網址鏈接(需在微信或者手Q中瀏覽) 從上圖可以看到,我們主要實作的功能也是本文要講解的有: ......

    uj5u.com 2023-01-04 07:29:05 more
  • 一文讀懂 HTTP/1 HTTP/2 HTTP/3

    從 1989 年萬維網(www)誕生,HTTP(HyperText Transfer Protocol)經歷了眾多版本迭代,WebSocket 也在期間萌芽。1991 年 HTTP0.9 被發明。1996 年出現了 HTTP1.0。2015 年 HTTP2 正式發布。2020 年 HTTP3 或能正... ......

    uj5u.com 2022-12-24 06:56:02 more
  • 【HTML基礎篇002】HTML之form表單超詳解

    ??一、form表單是什么

    ??二、form表單的屬性

    ??三、input中的各種Type屬性值

    ??四、標簽 ......

    uj5u.com 2022-12-18 07:17:06 more