虛擬dom
和diff
演算法是vue
學習過程中的一個難點,也是面試中必須掌握的一個知識點。這兩者相輔相成,是vue
框架的核心。今天我們再來總結下vue2
中的虛擬dom
和 diff
演算法。 (學習影片分享:vue影片教學)
什麼是VNode
我們知道,render function
會被轉化成VNode
。 VNode
其實就是一棵以 JavaScript
物件作為基礎的樹,用物件屬性來描述節點,其實它只是一層真實 DOM
的抽象。最終可以透過一系列操作使這棵樹映射到真實環境。
例如有如下template
<template> <span class="demo" v-show="isShow"> This is a span. </span> </template>
它換成VNode
以後大概就是下面這個樣子
{ tag: "span", data: { /* 指令集合数组 */ directives: [ { /* v-show指令 */ rawName: "v-show", expression: "isShow", name: "show", value: true, }, ], /* 静态class */ staticClass: "demo", }, text: undefined, children: [ /* 子节点是一个文本VNode节点 */ { tag: undefined, data: undefined, text: "This is a span.", children: undefined, }, ], };
總的來說,VNode
就是一個JavaScript
物件。這個JavaScript
物件能完整地表示出真實DOM
。
為什麼vue要使用VNode
筆者認為有兩點原因
由於
Virtual DOM
#是以JavaScript
物件為基礎而不依賴真實平台環境,所以使它具有了跨平台的能力,比如說瀏覽器平台、Weex、Node 等。減少操作
DOM
,任何頁面的變化,都只使用VNode
進行操作對比,只需要在最後一次進行掛載更新DOM
,避免了頻繁操作DOM
,減少了瀏覽器的回流和重繪從而提高頁面效能。
diff演算法
下面我們來看看元件更新所涉及的diff演算法
。
前面我們講依賴收集的時候有說到,渲染watcher
傳遞給Watcher
的get
方法其實是updateComponent
方法。
updateComponent = () => { vm._update(vm._render(), hydrating) } new Watcher(vm, updateComponent, noop, { before () { if (vm._isMounted) { callHook(vm, 'beforeUpdate') } } }, true /* isRenderWatcher */)
所以元件在響應式資料變更的時候會再次觸發方法,接下來我們來詳細分析一下updateComponent
裡面的_update
方法。
_update
在_update
方法中做了初始渲染和更新的區分,雖然都是呼叫__patch__
方法,但是傳遞的參數不一樣。
// src/core/instance/lifecycle.js Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { const vm: Component = this const prevEl = vm.$el const prevVnode = vm._vnode vm._vnode = vnode // 初次渲染没有 prevVnode,组件更新才会有 if (!prevVnode) { // 初次渲染 vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */) } else { // 更新 vm.$el = vm.__patch__(prevVnode, vnode) } // ... }
下面我們再來看看__patch__
方法
#__patch__
patch
方法接收四個參數,由於初始渲染的時候oldVnode
為vm.$el
是null
,所以初始渲染是沒有oldVnode
。
// src/core/vdom/patch.js return function patch (oldVnode, vnode, hydrating, removeOnly) { // 新节点不存在,只有oldVnode就直接销毁,然后返回 if (isUndef(vnode)) { if (isDef(oldVnode)) invokeDestroyHook(oldVnode) return } let isInitialPatch = false const insertedVnodeQueue = [] // 没有老节点,直接创建,也就是初始渲染 if (isUndef(oldVnode)) { isInitialPatch = true createElm(vnode, insertedVnodeQueue) } else { const isRealElement = isDef(oldVnode.nodeType) // 不是真实dom,并且是相同节点走patch if (!isRealElement && sameVnode(oldVnode, vnode)) { // 这里才会涉及到diff算法 patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly) } else { if (isRealElement) { // ... } // replacing existing element const oldElm = oldVnode.elm const parentElm = nodeOps.parentNode(oldElm) // 1.创建一个新节点 createElm( vnode, insertedVnodeQueue, // extremely rare edge case: do not insert if old element is in a // leaving transition. Only happens when combining transition + // keep-alive + HOCs. (#4590) oldElm._leaveCb ? null : parentElm, nodeOps.nextSibling(oldElm) ) // 2.更新父节点占位符 if (isDef(vnode.parent)) { let ancestor = vnode.parent const patchable = isPatchable(vnode) while (ancestor) { for (let i = 0; i < cbs.destroy.length; ++i) { cbs.destroy[i](ancestor) } ancestor.elm = vnode.elm if (patchable) { for (let i = 0; i < cbs.create.length; ++i) { cbs.create[i](emptyNode, ancestor) } const insert = ancestor.data.hook.insert if (insert.merged) { // start at index 1 to avoid re-invoking component mounted hook for (let i = 1; i < insert.fns.length; i++) { insert.fns[i]() } } } else { registerRef(ancestor) } ancestor = ancestor.parent } } // 3.删除老节点 if (isDef(parentElm)) { removeVnodes([oldVnode], 0, 0) } else if (isDef(oldVnode.tag)) { invokeDestroyHook(oldVnode) } } } //触发插入钩子 invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch) return vnode.elm }
patch
方法大概流程如下:
沒有新節點只有舊節點直接刪除舊節點。
只有新節點沒有舊節點直接新增節點。
既有新節點又有舊節點則判斷是不是相同節點,相同則進入
pathVnode
。patchVnode
我們後面會重點分析。既有新節點又有老節點則判斷是不是相同節點,不相同則直接刪除舊節點新增節點。
我們再來看看它是怎麼判斷是同一個節點的。
// src/core/vdom/patch.js function sameVnode (a, b) { return ( a.key === b.key && a.asyncFactory === b.asyncFactory && ( ( a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInputType(a, b) ) || ( isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error) ) ) ) } function sameInputType (a, b) { if (a.tag !== 'input') return true let i const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB) }
判斷兩個VNode
節點是否為同一個節點,需要同時滿足下列條件
##key
相同
- 都有非同步元件工廠函數
tag
(目前節點的標籤名稱)相同
#isComment
是否同為註解節點
- #是否
data
(目前節點對應的對象,包含了具體的一些資料訊息,是一個VNodeData型別)
- 當標籤是
的時候,
type必須相同
VNode的
tag、key、isComment都相同,並且同時定義或未定義
data的時候,且如果標籤為
input則type必須相同。這時候這兩個
VNode則算
sameVnode,可以直接進行
patchVnode操作。
patchVnode
下面我們再來詳細分析下patchVnode方法。
// src/core/vdom/patch.js function patchVnode ( oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly ) { // 两个vnode相同则直接返回 if (oldVnode === vnode) { return } if (isDef(vnode.elm) && isDef(ownerArray)) { // clone reused vnode vnode = ownerArray[index] = cloneVNode(vnode) } const elm = vnode.elm = oldVnode.elm if (isTrue(oldVnode.isAsyncPlaceholder)) { if (isDef(vnode.asyncFactory.resolved)) { hydrate(oldVnode.elm, vnode, insertedVnodeQueue) } else { vnode.isAsyncPlaceholder = true } return } /* 如果新旧VNode都是静态的,同时它们的key相同(代表同一节点), 并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次), 那么只需要替换componentInstance即可。 */ if (isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic) && vnode.key === oldVnode.key && (isTrue(vnode.isCloned) || isTrue(vnode.isOnce)) ) { vnode.componentInstance = oldVnode.componentInstance return } let i const data = vnode.data /*调用prepatch钩子*/ if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) { i(oldVnode, vnode) } // 获取新老虚拟节点的子节点 const oldCh = oldVnode.children const ch = vnode.children if (isDef(data) && isPatchable(vnode)) { for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode) if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode) } // 新节点不是文本节点 if (isUndef(vnode.text)) { /*新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren*/ if (isDef(oldCh) && isDef(ch)) { if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) /*如果只有新节点有子节点,先清空elm文本内容,然后为当前DOM节点加入子节点。*/ } else if (isDef(ch)) { if (process.env.NODE_ENV !== 'production') { checkDuplicateKeys(ch) } if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) /*如果只有老节点有子节点,则移除elm所有子节点*/ } else if (isDef(oldCh)) { removeVnodes(oldCh, 0, oldCh.length - 1) /*当新老节点都无子节点的时候,因为这个逻辑中新节点text不存在,所以直接去除ele的文本*/ } else if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, '') } // 新节点是文本节点,如果文本不一样就设置新的文本 } else if (oldVnode.text !== vnode.text) { nodeOps.setTextContent(elm, vnode.text) } /*调用postpatch钩子*/ if (isDef(data)) { if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode) } }
patchVnode方法大概流程如下:
2.如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),那么只需要替换componentInstance即可。
3.新节点不是文本节点,新老节点均有children
子节点,则对子节点进行diff
操作,调用updateChildren
,这个updateChildren
是diff算法
的核心,后面我们会重点说。
4.新节点不是文本节点,如果老节点没有子节点而新节点存在子节点,先清空老节点DOM的文本内容,然后为当前DOM节点加入子节点。
5.新节点不是文本节点,当新节点没有子节点而老节点有子节点的时候,则移除该DOM节点的所有子节点。
6.新节点不是文本节点,并且新老节点都无子节点的时候,只需要将老节点文本清空。
7.新节点是文本节点,并且新老节点文本不一样,则进行文本的替换。
updateChildren(diff算法核心)
updateChildren
是diff
算法的核心,下面我们来重点分析。
这两张图代表旧的VNode与新VNode进行patch的过程,他们只是在同层级的VNode之间进行比较得到变化(相同颜色的方块代表互相进行比较的VNode节点),然后修改变化的视图,所以十分高效。所以Diff算法是:深度优先算法
。 时间复杂度:O(n)
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { let oldStartIdx = 0 let newStartIdx = 0 let oldEndIdx = oldCh.length - 1 let oldStartVnode = oldCh[0] let oldEndVnode = oldCh[oldEndIdx] let newEndIdx = newCh.length - 1 let newStartVnode = newCh[0] let newEndVnode = newCh[newEndIdx] let oldKeyToIdx, idxInOld, vnodeToMove, refElm const canMove = !removeOnly if (process.env.NODE_ENV !== 'production') { checkDuplicateKeys(newCh) } while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] // 老 VNode 节点的头部与新 VNode 节点的头部是相同的 VNode 节点,直接进行 patchVnode,同时 oldStartIdx 与 newStartIdx 向后移动一位。 } else if (sameVnode(oldStartVnode, newStartVnode)) { patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] // 两个 VNode 的结尾是相同的 VNode,同样进行 patchVnode 操作。并将 oldEndVnode 与 newEndVnode 向前移动一位。 } else if (sameVnode(oldEndVnode, newEndVnode)) { patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] // oldStartVnode 与 newEndVnode 符合 sameVnode 的时候, // 将 oldStartVnode.elm 这个节点直接移动到 oldEndVnode.elm 这个节点的后面即可。 // 然后 oldStartIdx 向后移动一位,newEndIdx 向前移动一位。 } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] // oldEndVnode 与 newStartVnode 符合 sameVnode 时, // 将 oldEndVnode.elm 插入到 oldStartVnode.elm 前面。 // oldEndIdx 向前移动一位,newStartIdx 向后移动一位。 } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { // createKeyToOldIdx 的作用是产生 key 与 index 索引对应的一个 map 表 if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) // 如果没有找到相同的节点,则通过 createElm 创建一个新节点,并将 newStartIdx 向后移动一位。 if (isUndef(idxInOld)) { // New element createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else { vnodeToMove = oldCh[idxInOld] // 如果找到了节点,同时它符合 sameVnode,则将这两个节点进行 patchVnode,将该位置的老节点赋值 undefined // 同时将 newStartVnode.elm 插入到 oldStartVnode.elm 的前面 if (sameVnode(vnodeToMove, newStartVnode)) { patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) oldCh[idxInOld] = undefined canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) } else { // 如果不符合 sameVnode,只能创建一个新节点插入到 parentElm 的子节点中,newStartIdx 往后移动一位。 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } } newStartVnode = newCh[++newStartIdx] } } // 当 while 循环结束以后,如果 oldStartIdx > oldEndIdx,说明老节点比对完了,但是新节点还有多的, // 需要将新节点插入到真实 DOM 中去,调用 addVnodes 将这些节点插入即可。 if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) // 如果满足 newStartIdx > newEndIdx 条件,说明新节点比对完了,老节点还有多, // 将这些无用的老节点通过 removeVnodes 批量删除即可。 } else if (newStartIdx > newEndIdx) { removeVnodes(oldCh, oldStartIdx, oldEndIdx) } }
vue2
的diff
算法采用的是双端比较
,所谓双端比较
就是新列表和旧列表两个列表的头与尾互相对比,在对比的过程中指针会逐渐向内靠拢,直到某一个列表的节点全部遍历过,对比停止。
首尾对比的四种情况
我们首先来看看首尾对比的四种情况。
使用旧列表的头一个节点
oldStartNode
与新列表的头一个节点newStartNode
对比使用旧列表的最后一个节点
oldEndNode
与新列表的最后一个节点newEndNode
对比使用旧列表的头一个节点
oldStartNode
与新列表的最后一个节点newEndNode
对比使用旧列表的最后一个节点
oldEndNode
与新列表的头一个节点newStartNode
对比
首先是 oldStartVnode
与 newStartVnode
符合 sameVnode
时,说明老 VNode 节点的头部与新 VNode 节点的头部是相同的 VNode 节点,直接进行 patchVnode
,同时 oldStartIdx
与 newStartIdx
向后移动一位。
其次是 oldEndVnode
与 newEndVnode
符合 sameVnode
,也就是两个 VNode 的结尾是相同的 VNode,同样进行 patchVnode
操作并将 oldEndVnode
与 newEndVnode
向前移动一位。
接下来是两种交叉的情况。
先是 oldStartVnode
与 newEndVnode
符合 sameVnode
的时候,也就是老 VNode 节点的头部与新 VNode 节点的尾部是同一节点的时候,将 oldStartVnode.elm
这个节点直接移动到 oldEndVnode.elm
这个节点的后面即可。然后 oldStartIdx
向后移动一位,newEndIdx
向前移动一位。
同理,oldEndVnode
与 newStartVnode
符合 sameVnode
时,也就是老 VNode 节点的尾部与新 VNode 节点的头部是同一节点的时候,将 oldEndVnode.elm
插入到 oldStartVnode.elm
前面。同样的,oldEndIdx
向前移动一位,newStartIdx
向后移动一位。
最後是以上情況都不符合的時候,這種情況怎麼處理呢?
找出對比
那就是找出對比。
首先透過createKeyToOldIdx
方法產生oldVnode
的key
與 index
索引對應的一個map
# 表。
然後我們根據newStartVnode.key
,可以快速地從 oldKeyToIdx
(createKeyToOldIdx
的回傳值)取得相同key
的節點的索引 idxInOld
,然後找到相同的節點。
這裡又分三種情況
如果沒有找到相同的節點,則透過
createElm
建立一個新節點,並將# newStartIdx
向後移動一位。如果找到了節點,同時它符合
sameVnode
,則將這兩個節點進行patchVnode
,將該位置的舊節點賦值undefined
(之後如果還有新節點與該節點key相同可以偵測出來提示已有重複的key ),同時將newStartVnode.elm
插入oldStartVnode.elm
的前面。同理,newStartIdx
往後移動一位。
如果不符合
sameVnode
,只能建立一個新節點插入到parentElm
的子節點中,newStartIdx
往後移動一位。
新增、刪除節點
最後一步就很容易啦,當 while
循環結束以後,如果 oldStartIdx > oldEndIdx
,表示舊節點比對完了,但是新節點還有多的,需要將新節點插入到真實DOM 中去,調用 addVnodes
將這些節點插入即可。
同理,如果滿足 newStartIdx > newEndIdx
條件,說明新節點比對完了,老節點還有多,將這些無用的老節點透過 removeVnodes
批次刪除即可。
總結
Diff演算法是一種對比演算法。比較兩者是舊虛擬DOM和新虛擬DOM
,對比出是哪個虛擬節點
更改了,找出這個虛擬節點
,並且只更新這個虛擬節點所對應的真實節點
,而不用更新其他資料沒發生改變的節點,實現精準
地更新真實DOM,進而提高效率和效能
。
精準
主要體現在,diff
演算法首先就是找到可重複使用的節點,然後移動到正確的位置。當元素沒有找到的話再來建立新節點。
擴充
vue中為什麼需要使用key,它的作用是什麼?
key
是Vue
中vnode
的唯一標記,透過這個key
,diff
操作可以更準確、更快速。
- 更準確:因為帶
key
就不是就地復用了,在sameNode
函數a.key === b.key
對比中可以避免就地復用的情況。所以會更加準確。 - 更快:利用
key
的唯一性產生map
物件來取得對應節點,比遍歷方式更快。
為什麼不推薦使用index作為key
當我們的清單只涉及到展示,不涉及排序、刪除、新增的時候使用index
作為key
是沒什麼問題的。因為此時的index
在每個元素上是唯一的。
但是如果涉及排序、刪除、新增的時候就不能再使用index
作為key
了,因為每個元素key
不再唯一了。不唯一的key
,對diff
演算法沒有任何幫助,寫和沒寫是一樣的。
以上是深入理解vue2中的VNode和diff演算法的詳細內容。更多資訊請關注PHP中文網其他相關文章!

NetflixusesAcustomFrameworkcalled“ Gibbon” BuiltonReact,notReactorVuedIrectly.1)TeamSperience:selectBasedonFamiliarity.2)ProjectComplexity:vueforsimplerprojects:reactforforforproproject,reactforforforcompleplexones.3)cocatizationneedneeds:reactoffipicatizationneedneedneedneedneedneeds:reactoffersizationneedneedneedneedneeds:reactoffersizatization needefersmoreflexibleise.4)

Netflix在框架選擇上主要考慮性能、可擴展性、開發效率、生態系統、技術債務和維護成本。 1.性能與可擴展性:選擇Java和SpringBoot以高效處理海量數據和高並發請求。 2.開發效率與生態系統:使用React提升前端開發效率,利用其豐富的生態系統。 3.技術債務與維護成本:選擇Node.js構建微服務,降低維護成本和技術債務。

Netflix主要使用React作為前端框架,輔以Vue用於特定功能。 1)React的組件化和虛擬DOM提升了Netflix應用的性能和開發效率。 2)Vue在Netflix的內部工具和小型項目中應用,其靈活性和易用性是關鍵。

Vue.js是一種漸進式JavaScript框架,適用於構建複雜的用戶界面。 1)其核心概念包括響應式數據、組件化和虛擬DOM。 2)實際應用中,可以通過構建Todo應用和集成VueRouter來展示其功能。 3)調試時,建議使用VueDevtools和console.log。 4)性能優化可通過v-if/v-show、列表渲染優化和異步加載組件等實現。

Vue.js適合小型到中型項目,而React更適用於大型、複雜應用。 1.Vue.js的響應式系統通過依賴追踪自動更新DOM,易於管理數據變化。 2.React採用單向數據流,數據從父組件流向子組件,提供明確的數據流向和易於調試的結構。

Vue.js適合中小型項目和快速迭代,React適用於大型複雜應用。 1)Vue.js易於上手,適用於團隊經驗不足或項目規模較小的情況。 2)React的生態系統更豐富,適合有高性能需求和復雜功能需求的項目。

實現 Vue 中 a 標籤跳轉的方法包括:HTML 模板中使用 a 標籤指定 href 屬性。使用 Vue 路由的 router-link 組件。使用 JavaScript 的 this.$router.push() 方法。可通過 query 參數傳遞參數,並在 router 選項中配置路由以進行動態跳轉。

Vue 中實現組件跳轉有以下方法:使用 router-link 和 <router-view> 組件進行超鏈接跳轉,指定 :to 屬性為目標路徑。直接使用 <router-view> 組件顯示當前路由渲染的組件。使用 router.push() 和 router.replace() 方法進行程序化導航,前者保存歷史記錄,後者替換當前路由不留記錄。


熱AI工具

Undresser.AI Undress
人工智慧驅動的應用程序,用於創建逼真的裸體照片

AI Clothes Remover
用於從照片中去除衣服的線上人工智慧工具。

Undress AI Tool
免費脫衣圖片

Clothoff.io
AI脫衣器

AI Hentai Generator
免費產生 AI 無盡。

熱門文章

熱工具

VSCode Windows 64位元 下載
微軟推出的免費、功能強大的一款IDE編輯器

Dreamweaver CS6
視覺化網頁開發工具

WebStorm Mac版
好用的JavaScript開發工具

Safe Exam Browser
Safe Exam Browser是一個安全的瀏覽器環境,安全地進行線上考試。該軟體將任何電腦變成一個安全的工作站。它控制對任何實用工具的訪問,並防止學生使用未經授權的資源。

禪工作室 13.0.1
強大的PHP整合開發環境