前言
回應式原理作為 Vue 的核心,使用資料劫持實作資料驅動視圖,在面試中是經常考查的知識點,也是面試加分項,
本文將會循序漸進的決議回應式原理的作業流程,主要以下面結構進行:
- 分析主要成員,了解它們有助于理解流程
- 將流程拆分,理解其中的作用
- 結合以上的點,理解整體流程
文章稍長,但部分是代碼,還請耐心觀看,為了方便理解原理,文中的代碼會進行簡化,如果可以請對照原始碼學習,
主要成員
在回應式原理中,Observe、Dep、Watcher 這三個類是構成完整原理的主要成員,
Observe,回應式原理的入口,根據資料型別處理觀測邏輯Dep,依賴收集器,屬性都會有一個Dep,方便發生變化時能夠找到對應的依賴觸發更新Watcher,用于執行更新渲染,組件會擁有一個渲染Watcher,我們常說的收集依賴,就是收集Watcher
下面來看看這些類的實作,包含哪些主要屬性和方法,
Observe:我會對資料進行觀測
溫馨提示:代碼里的序號對應代碼塊下面序號的講解
// 原始碼位置:/src/core/observer/index.js
class Observe {
constructor(data) {
this.dep = new Dep()
// 1
def(data, '__ob__', this)
if (Array.isArray(data)) {
// 2
protoAugment(data, arrayMethods)
// 3
this.observeArray(data)
} else {
// 4
this.walk(data)
}
}
walk(data) {
Object.keys(data).forEach(key => {
defineReactive(data, key, data[key])
})
}
observeArray(data) {
data.forEach(item => {
observe(item)
})
}
}
- 為觀測的屬性添加
__ob__屬性,它的值等于this,即當前Observe的實體 - 為陣列添加重寫的陣列方法,比如:
push、unshift、splice等方法,重寫目的是在呼叫這些方法時,進行更新渲染 - 觀測陣列內的資料,
observe內部會呼叫new Observe,形成遞回觀測 - 觀測物件資料,
defineReactive為資料定義get和set,即資料劫持
Dep:我會為資料收集依賴
// 原始碼位置:/src/core/observer/dep.js
let id = 0
class Dep{
constructor() {
this.id = ++id // dep 唯一標識
this.subs = [] // 存盤 Watcher
}
// 1
depend() {
Dep.target.addDep(this)
}
// 2
addSub(watcher) {
this.subs.push(watcher)
}
// 3
notify() {
this.subs.forEach(watcher => watcher.update())
}
}
// 4
Dep.target = null
export function pushTarget(watcher) {
Dep.target = watcher
}
export function popTarget(){
Dep.target = null
}
export default Dep
- 資料收集依賴的主要方法,
Dep.target是一個watcher實體 - 添加
watcher到陣列中,也就是添加依賴 - 屬性在變化時會呼叫
notify方法,通知每一個依賴進行更新 Dep.target用來記錄watcher實體,是全域唯一的,主要作用是為了在收集依賴的程序中找到相應的watcher
pushTarget 和 popTarget 這兩個方法顯而易見是用來設定 Dep.target的,Dep.target 也是一個關鍵點,這個概念可能初次查看原始碼會有些難以理解,在后面的流程中,會詳細講解它的作用,需要注意這部分的內容,
Watcher:我會觸發視圖更新
// 原始碼位置:/src/core/observer/watcher.js
let id = 0
export class Watcher {
constructor(vm, exprOrFn, cb, options){
this.id = ++id // watcher 唯一標識
this.vm = vm
this.cb = cb
this.options = options
// 1
this.getter = exprOrFn
this.deps = []
this.depIds = new Set()
this.get()
}
run() {
this.get()
}
get() {
pushTarget(this)
this.getter()
popTarget(this)
}
// 2
addDep(dep) {
// 防止重復添加 dep
if (!this.depIds.has(dep.id)) {
this.depIds.add(dep.id)
this.deps.push(dep)
dep.addSub(this)
}
}
// 3
update() {
queueWatcher(this)
}
}
this.getter存盤的是更新視圖的函式watcher存盤dep,同時dep也存盤watcher,進行雙向記錄- 觸發更新,
queueWatcher是為了進行異步更新,異步更新會呼叫run方法進行更新頁面
回應式原理流程
對于以上這些成員具有的功能,我們都有大概的了解,下面結合它們,來看看這些功能是如何在回應式原理流程中作業的,
資料觀測
資料在初始化時會通過 observe 方法來呼叫 Observe
// 原始碼位置:/src/core/observer/index.js
export function observe(data) {
// 1
if (!isObject(data)) {
return
}
let ob;
// 2
if (data.hasOwnProperty('__ob__') && data.__ob__ instanceof Observe) {
ob = data.__ob__
} else {
// 3
ob = new Observe(data)
}
return ob
}
在初始化時,observe 拿到的 data 就是我們在 data 函式內回傳的物件,
observe函式只對object型別資料進行觀測- 觀測過的資料都會被添加上
__ob__屬性,通過判斷該屬性是否存在,防止重復觀測 - 創建
Observe實體,開始處理觀測邏輯
物件觀測
進入 Observe 內部,由于初始化的資料是一個物件,所以會呼叫 walk 方法:
walk(data) {
Object.keys(data).forEach(key => {
defineReactive(data, key, data[key])
})
}
defineReactive 方法內部使用 Object.defineProperty 對資料進行劫持,是實作回應式原理最核心的地方,
function defineReactive(obj, key, value) {
// 1
let childOb = observe(value)
// 2
const dep = new Dep()
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
// 3
dep.depend()
if (childOb) {
childOb.dep.depend()
}
}
return value
},
set(newVal) {
if (newVal === value) {
return
}
value = https://www.cnblogs.com/chanwahfung/p/newVal
// 4
childOb = observe(newVal)
// 5
dep.notify()
return value
}
})
}
- 由于值可能是物件型別,這里需要呼叫
observe進行遞回觀測 - 這里的
dep就是上面講到的每一個屬性都會有一個dep,它是作為一個閉包的存在,負責收集依賴和通知更新 - 在初始化時,
Dep.target是組件的渲染watcher,這里dep.depend收集的依賴就是這個watcher,childOb.dep.depend主要是為陣列收集依賴 - 設定的新值可能是物件型別,需要對新值進行觀測
- 值發生改變,
dep.notify通知watcher更新,這是我們改變資料后能夠實時更新頁面的觸發點
通過 Object.defineProperty 對屬性定義后,屬性的獲取觸發 get 回呼,屬性的設定觸發 set 回呼,實作回應式更新,
通過上面的邏輯,也能得出為什么 Vue3.0 要使用 Proxy 代替 Object.defineProperty 了,Object.defineProperty 只能對單個屬性進行定義,如果屬性是物件型別,還需要遞回去觀測,會很消耗性能,而 Proxy 是代理整個物件,只要屬性發生變化就會觸發回呼,
陣列觀測
對于陣列型別觀測,會呼叫 observeArray 方法:
observeArray(data) {
data.forEach(item => {
observe(item)
})
}
與物件不同,它執行 observe 對陣列內的物件型別進行觀測,并沒有對陣列的每一項進行 Object.defineProperty 的定義,也就是說陣列內的項是沒有 dep 的,
所以,我們通過陣列索引對項進行修改時,是不會觸發更新的,但可以通過 this.$set 來修改觸發更新,那么問題來了,為什么 Vue 要這樣設計?
結合實際場景,陣列中通常會存放多項資料,比如串列資料,這樣觀測起來會消耗性能,還有一點原因,一般修改陣列元素很少會直接通過索引將整個元素替換掉,例如:
export default {
data() {
return {
list: [
{id: 1, name: 'Jack'},
{id: 2, name: 'Mike'}
]
}
},
cretaed() {
// 如果想要修改 name 的值,一般是這樣使用
this.list[0].name = 'JOJO'
// 而不是以下這樣
// this.list[0] = {id:1, name: 'JOJO'}
// 當然你可以這樣更新
// this.$set(this.list, '0', {id:1, name: 'JOJO'})
}
}
陣列方法重寫
當陣列元素新增或洗掉,視圖會隨之更新,這并不是理所當然的,而是 Vue 內部重寫了陣列的方法,呼叫這些方法時,陣列會更新檢測,觸發視圖更新,這些方法包括:
- push()
- pop()
- shift()
- unshift()
- splice()
- sort()
- reverse()
回到 Observe 的類中,當觀測的資料型別為陣列時,會呼叫 protoAugment 方法,
if (Array.isArray(data)) {
protoAugment(data, arrayMethods)
// 觀察陣列
this.observeArray(data)
} else {
// 觀察物件
this.walk(data)
}
這個方法里把陣列原型替換為 arrayMethods ,當呼叫改變陣列的方法時,優先使用重寫后的方法,
function protoAugment(data, arrayMethods) {
data.__proto__ = arrayMethods
}
接下來看看 arrayMethods 是如何實作的:
// 原始碼位置:/src/core/observer/array.js
// 1
let arrayProto = Array.prototype
// 2
export let arrayMethods = Object.create(arrayProto)
let methods = [
'push',
'pop',
'shift',
'unshift',
'reverse',
'sort',
'splice'
]
methods.forEach(method => {
arrayMethods[method] = function(...args) {
// 3
let res = arrayProto[method].apply(this, args)
let ob = this.__ob__
let inserted = ''
switch(method){
case 'push':
case 'unshift':
inserted = args
break;
case 'splice':
inserted = args.slice(2)
break;
}
// 4
inserted && ob.observeArray(inserted)
// 5
ob.dep.notify()
return res
}
})
- 將陣列的原型保存起來,因為重寫的陣列方法里,還是需要呼叫原生陣列方法的
arrayMethods是一個物件,用于保存重寫的方法,這里使用Object.create(arrayProto)創建物件是為了使用者在呼叫非重寫方法時,能夠繼承使用原生的方法- 呼叫原生方法,存盤回傳值,用于設定重寫函式的回傳值
inserted存盤新增的值,若inserted存在,對新值進行觀測ob.dep.notify觸發視圖更新
依賴收集
依賴收集是視圖更新的前提,也是回應式原理中至關重要的環節,
偽代碼流程
為了方便理解,這里寫一段偽代碼,大概了解依賴收集的流程:
// data 資料
let data = https://www.cnblogs.com/chanwahfung/p/{
name:'joe'
}
// 渲染watcher
let watcher = {
run() {
dep.tagret = watcher
document.write(data.name)
}
}
// dep
let dep = [] // 存盤依賴
dep.tagret = null // 記錄 watcher
// 資料劫持
let oldValue = https://www.cnblogs.com/chanwahfung/p/data.name
Object.defineProperty(data,'name', {
get(){
// 收集依賴
dep.push(dep.tagret)
return oldValue
},
set(newVal){
oldValue = https://www.cnblogs.com/chanwahfung/p/newVal
dep.forEach(watcher => {
watcher.run()
})
}
})
初始化:
- 首先會對
name屬性定義get和set - 然后初始化會執行一次
watcher.run渲染頁面 - 這時候獲取
data.name,觸發get函式收集依賴,
更新:
修改 data.name,觸發 set 函式,呼叫 run 更新視圖,
真正流程
下面來看看真正的依賴收集流程是如何進行的,
function defineReactive(obj, key, value) {
let childOb = observe(value)
const dep = new Dep()
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.depend() // 收集依賴
if (childOb) {
childOb.dep.depend()
}
}
return value
},
set(newVal) {
if (newVal === value) {
return
}
value = https://www.cnblogs.com/chanwahfung/p/newVal
childOb = observe(newVal)
dep.notify()
return value
}
})
}
首先初始化資料,呼叫 defineReactive 函式對資料進行劫持,
export class Watcher {
constructor(vm, exprOrFn, cb, options){
this.getter = exprOrFn
this.get()
}
get() {
pushTarget(this)
this.getter()
popTarget(this)
}
}
初始化將 watcher 掛載到 Dep.target,this.getter 開始渲染頁面,渲染頁面需要對資料取值,觸發 get 回呼,dep.depend 收集依賴,
class Dep{
constructor() {
this.id = id++
this.subs = []
}
depend() {
Dep.target.addDep(this)
}
}
Dep.target 為 watcher,呼叫 addDep 方法,并傳入 dep 實體,
export class Watcher {
constructor(vm, exprOrFn, cb, options){
this.deps = []
this.depIds = new Set()
}
addDep(dep) {
if (!this.depIds.has(dep.id)) {
this.depIds.add(dep.id)
this.deps.push(dep)
dep.addSub(this)
}
}
}
addDep 中添加完 dep 后,呼叫 dep.addSub 并傳入當前 watcher 實體,
class Dep{
constructor() {
this.id = id++
this.subs = []
}
addSub(watcher) {
this.subs.push(watcher)
}
}
將傳入的 watcher 收集起來,至此依賴收集流程完畢,
補充一點,通常頁面上會系結很多屬性變數,渲染會對屬性取值,此時每個屬性收集的依賴都是同一個 watcher,即組件的渲染 watcher,
陣列的依賴收集
methods.forEach(method => {
arrayMethods[method] = function(...args) {
let res = arrayProto[method].apply(this, args)
let ob = this.__ob__
let inserted = ''
switch(method){
case 'push':
case 'unshift':
inserted = args
break;
case 'splice':
inserted = args.slice(2)
break;
}
// 對新增的值觀測
inserted && ob.observeArray(inserted)
// 更新視圖
ob.dep.notify()
return res
}
})
還記得重寫的方法里,會呼叫 ob.dep.notify 更新視圖,__ob__ 是我們在 Observe 為觀測資料定義的標識,值為 Observe 實體,那么 ob.dep 的依賴是在哪里收集的?
function defineReactive(obj, key, value) {
// 1
let childOb = observe(value)
const dep = new Dep()
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.depend()
// 2
if (childOb) {
childOb.dep.depend()
}
}
return value
},
set(newVal) {
if (newVal === value) {
return
}
value = https://www.cnblogs.com/chanwahfung/p/newVal
childOb = observe(newVal)
dep.notify()
return value
}
})
}
observe函式回傳值為Observe實體childOb.dep.depend執行,為Observe實體的dep添加依賴
所以在陣列更新時,ob.dep 內已經收集到依賴了,
整體流程
下面捋一遍初始化流程和更新流程,如果你是初次看原始碼,不知道從哪里看起,也可以參照以下的順序,由于原始碼實作比較多,下面展示的原始碼會稍微刪減一些代碼
初始化流程
入口檔案:
// 原始碼位置:/src/core/instance/index.js
import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'
function Vue (options) {
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
export default Vue
_init:
// 原始碼位置:/src/core/instance/init.js
export function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
// a uid
vm._uid = uid++
// merge options
if (options && options._isComponent) {
// optimize internal component instantiation
// since dynamic options merging is pretty slow, and none of the
// internal component options needs special treatment.
initInternalComponent(vm, options)
} else {
// mergeOptions 對 mixin 選項和傳入的 options 選項進行合并
// 這里的 $options 可以理解為 new Vue 時傳入的物件
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
// expose real self
vm._self = vm
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
// 初始化資料
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')
if (vm.$options.el) {
// 初始化渲染頁面 掛載組件
vm.$mount(vm.$options.el)
}
}
}
上面主要關注兩個函式,initState 初始化資料,vm.$mount(vm.$options.el) 初始化渲染頁面,
先進入 initState:
// 原始碼位置:/src/core/instance/state.js
export function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
// data 初始化
initData(vm)
} else {
observe(vm._data = https://www.cnblogs.com/chanwahfung/p/{}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
function initData (vm: Component) {
let data = vm.$options.data
// data 為函式時,執行 data 函式,取出回傳值
data = vm._data = typeof data ==='function'
? getData(data, vm)
: data || {}
// proxy data on instance
const keys = Object.keys(data)
const props = vm.$options.props
const methods = vm.$options.methods
let i = keys.length
while (i--) {
const key = keys[i]
if (props && hasOwn(props, key)) {
process.env.NODE_ENV !== 'production' && warn(
`The data property "${key}" is already declared as a prop. ` +
`Use prop default value instead.`,
vm
)
} else if (!isReserved(key)) {
proxy(vm, `_data`, key)
}
}
// observe data
// 這里就開始走觀測資料的邏輯了
observe(data, true /* asRootData */)
}
observe 內部流程在上面已經講過,這里再簡單過一遍:
new Observe觀測資料defineReactive對資料進行劫持
initState 邏輯執行完畢,回到開頭,接下來執行 vm.$mount(vm.$options.el) 渲染頁面:
$mount:
// 原始碼位置:/src/platforms/web/runtime/index.js
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
mountComponent:
// 原始碼位置:/src/core/instance/lifecycle.js
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
callHook(vm, 'beforeMount')
let updateComponent
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
updateComponent = () => {
const name = vm._name
const id = vm._uid
const startTag = `vue-perf-start:${id}`
const endTag = `vue-perf-end:${id}`
mark(startTag)
const vnode = vm._render()
mark(endTag)
measure(`vue ${name} render`, startTag, endTag)
mark(startTag)
vm._update(vnode, hydrating)
mark(endTag)
measure(`vue ${name} patch`, startTag, endTag)
}
} else {
// 資料改變時 會呼叫此方法
updateComponent = () => {
// vm._render() 回傳 vnode,這里面會就對 data 資料進行取值
// vm._update 將 vnode 轉為真實dom,渲染到頁面上
vm._update(vm._render(), hydrating)
}
}
// 執行 Watcher,這個就是上面所說的渲染wacther
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
// manually mounted instance, call mounted on self
// mounted is called for render-created child components in its inserted hook
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
Watcher:
// 原始碼位置:/src/core/observer/watcher.js
let uid = 0
export default class Watcher {
constructor(vm, exprOrFn, cb, options){
this.id = ++id
this.vm = vm
this.cb = cb
this.options = options
// exprOrFn 就是上面傳入的 updateComponent
this.getter = exprOrFn
this.deps = []
this.depIds = new Set()
this.get()
}
get() {
// 1. pushTarget 將當前 watcher 記錄到 Dep.target,Dep.target 是全域唯一的
pushTarget(this)
let value
const vm = this.vm
try {
// 2. 呼叫 this.getter 相當于會執行 vm._render 函式,對實體上的屬性取值,
//由此觸發 Object.defineProperty 的 get 方法,在 get 方法內進行依賴收集(dep.depend),這里依賴收集就需要用到 Dep.target
value = https://www.cnblogs.com/chanwahfung/p/this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value)
}
// 3. popTarget 將 Dep.target 置空
popTarget()
this.cleanupDeps()
}
return value
}
}
至此初始化流程完畢,初始化流程的主要作業是資料劫持、渲染頁面和收集依賴,
更新流程
資料發生變化,觸發 set ,執行 dep.notify
// 原始碼位置:/src/core/observer/dep.js
let uid = 0
/**
* A dep is an observable that can have multiple
* directives subscribing to it.
*/
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
constructor () {
this.id = uid++
this.subs = []
}
addSub (sub: Watcher) {
this.subs.push(sub)
}
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () {
// stabilize the subscriber list first
const subs = this.subs.slice()
if (process.env.NODE_ENV !== 'production' && !config.async) {
// subs aren't sorted in scheduler if not running async
// we need to sort them now to make sure they fire in correct
// order
subs.sort((a, b) => a.id - b.id)
}
for (let i = 0, l = subs.length; i < l; i++) {
// 執行 watcher 的 update 方法
subs[i].update()
}
}
}
wathcer.update:
// 原始碼位置:/src/core/observer/watcher.js
/**
* Subscriber interface.
* Will be called when a dependency changes.
*/
update () {
/* istanbul ignore else */
if (this.lazy) { // 計算屬性更新
this.dirty = true
} else if (this.sync) { // 同步更新
this.run()
} else {
// 一般的資料都會進行異步更新
queueWatcher(this)
}
}
queueWatcher:
// 原始碼位置:/src/core/observer/scheduler.js
// 用于存盤 watcher
const queue: Array<Watcher> = []
// 用于 watcher 去重
let has: { [key: number]: ?true } = {}
/**
* Flush both queues and run the watchers.
*/
function flushSchedulerQueue () {
let watcher, id
// 對 watcher 排序
queue.sort((a, b) => a.id - b.id)
// do not cache length because more watchers might be pushed
// as we run existing watchers
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
id = watcher.id
has[id] = null
// run方法更新視圖
watcher.run()
}
}
/**
* Push a watcher into the watcher queue.
* Jobs with duplicate IDs will be skipped unless it's
* pushed when the queue is being flushed.
*/
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
// watcher 加入陣列
queue.push(watcher)
// 異步更新
nextTick(flushSchedulerQueue)
}
}
nextTick:
// 原始碼位置:/src/core/util/next-tick.js
const callbacks = []
let pending = false
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
// 遍歷回呼函式執行
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
let timerFunc
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
}
}
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
// 將回呼函式加入陣列
callbacks.push(() => {
if (cb) {
cb.call(ctx)
}
})
if (!pending) {
pending = true
// 遍歷回呼函式執行
timerFunc()
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
這一步是為了使用微任務將回呼函式異步執行,也就是上面的p.then,最終,會呼叫 watcher.run 更新頁面,
至此更新流程完畢,
寫在最后
如果沒有接觸過原始碼的同學,我相信看完可能還是會有點懵的,這很正常,建議對照原始碼再自己多看幾遍就能知道流程了,對于有基礎的同學就當做是復習了,
想要變強,學會看原始碼是必經之路,在這程序中,不僅能學習框架的設計思想,還能培養自己的邏輯思維,萬事開頭難,遲早都要邁出這一步,不如就從今天開始,
簡化后的代碼我已放在 github,有需要的可以看看,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/60311.html
標籤:JavaScript
