摘要:通過的合并策略合并添加項到新的構造器上緩存父構造器處理和相關響應式配置項在新的構造器上掛上的工具方法緩存組件構造器在上總的來說是返回了一個帶有附加配置相的新的的構造器。在函數中,構造器叫做,等待時候初始化。
身為原來的jquery,angular使用者。后面接觸了react和vue。漸漸的喜歡上了vue。抱著學習的態(tài)度呀。看看源碼。果然菜要付出代價。一步步單步調試。頭好疼。看到哪里記到哪里。來一點點心得。錯誤的地方請幫我指出來。謝謝。最近看的是vue component部分。
先上一段最簡單的代碼,來剖析component機制:
{{a}} 111{{a}}
我們按照瀏覽器的思維逐行來。執(zhí)行到腳本時。首先執(zhí)行了
Vue.component("my-component", { name: "my-component", template: "A custom component!", components: { Child:Child }, created(){ console.log(this); }, mounted(){ console.log(this); } })
我們來看看這個函數經歷了什么:
vue.js初始化的時候。調用了initGlobalAPI(vue),為vue掛上了工具函數vue.component
經過initGlobalAPI(vue)中的initAssetRegisters (Vue) 后。變?yōu)?/p>
vue.component = function ( id, definition ) { if (!definition) { return this.options[type + "s"][id] } else { /* istanbul ignore if */ { if (type === "component" && config.isReservedTag(id)) { warn( "Do not use built-in or reserved HTML elements as component " + "id: " + id ); } } if (type === "component" && isPlainObject(definition)) { definition.name = definition.name || id; definition = this.options._base.extend(definition); } if (type === "directive" && typeof definition === "function") { definition = { bind: definition, update: definition }; } this.options[type + "s"][id] = definition;//全局的組件或者指令和過濾器。統(tǒng)一掛在vue.options上。等待init的時候利用策略合并侵入實例。供實例使用 return definition } };
this.options._base在initGlobalAPI(vue)中為Vue.options._base = Vue;
so vue.component調用了vue.extend。找到了源頭。我們來好好看看這個vue.extend這個function。代碼如下:
Vue.extend = function (extendOptions) { extendOptions = extendOptions || {}; var Super = this; var SuperId = Super.cid; var cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {}); if (cachedCtors[SuperId]) { return cachedCtors[SuperId]//如果組件已經被緩存在extendOptions上則直接取出 } var name = extendOptions.name || Super.options.name; { if (!/^[a-zA-Z][w-]*$/.test(name)) { warn( "Invalid component name: "" + name + "". Component names " + "can only contain alphanumeric characters and the hyphen, " + "and must start with a letter."//校驗組件名 ); } } var Sub = function VueComponent (options) { this._init(options); }; Sub.prototype = Object.create(Super.prototype); Sub.prototype.constructor = Sub;//將vue上原型的方法掛在Sub.prototype中,Sub的實例同時也繼承了vue.prototype上的所有屬性和方法。 Sub.cid = cid++; Sub.options = mergeOptions( Super.options, extendOptions//通過vue的合并策略合并添加項到新的構造器上 ); Sub["super"] = Super;緩存父構造器 // For props and computed properties, we define the proxy getters on // the Vue instances at extension time, on the extended prototype. This // avoids Object.defineProperty calls for each instance created. if (Sub.options.props) { initProps$1(Sub); } if (Sub.options.computed) { //處理props和computed相關響應式配置項 initComputed$1(Sub); } // allow further extension/mixin/plugin usage Sub.extend = Super.extend; Sub.mixin = Super.mixin; Sub.use = Super.use; // create asset registers, so extended classes // can have their private assets too. //在新的構造器上掛上vue的工具方法 ASSET_TYPES.forEach(function (type) { Sub[type] = Super[type]; }); // enable recursive self-lookup if (name) { Sub.options.components[name] = Sub; } // keep a reference to the super options at extension time. // later at instantiation we can check if Super"s options have // been updated. Sub.superOptions = Super.options; Sub.extendOptions = extendOptions; Sub.sealedOptions = extend({}, Sub.options); // cache constructor cachedCtors[SuperId] = Sub;//緩存組件構造器在extendOptions上 return Sub }; } function initProps$1 (Comp) { var props = Comp.options.props; for (var key in props) { proxy(Comp.prototype, "_props", key); } } function initComputed$1 (Comp) { var computed = Comp.options.computed; for (var key in computed) { defineComputed(Comp.prototype, key, computed[key]); } }
總的來說vue.extend是返回了一個帶有附加配置相的新的vue的構造器。在函數中,構造器叫做Sub,等待render時候初始化。
經過vue.component的調用。vue增加了一個全局組件my-component;此時vue.options.component如下圖:
前三個是vue內置的三個組件,在initgloabalapi的時候初始化。
至此全局組件創(chuàng)建完成。全局組件放置在最底層。在以后的策略合并里會在子組件中的component項的__proto__中。
上圖:
vue官方的生命周期圖,其實也就是vue組件的構成的生命周期。沿著new Vue()我們來大概看看這些生命周期在什么階段
Vue.prototype._init = function (options) { var vm = this; // a uid vm._uid = uid$1++; var startTag, endTag; /* istanbul ignore if */ if ("development" !== "production" && config.performance && mark) { startTag = "vue-perf-init:" + (vm._uid); endTag = "vue-perf-end:" + (vm._uid); mark(startTag); } // a flag to avoid this being observed vm._isVue = true; // 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 { vm.$options = mergeOptions( resolveConstructorOptions(vm.constructor),//策略合并,每項屬性都有對應的合并規(guī)則 options || {}, vm ); } /* istanbul ignore else */ { initProxy(vm);//屬性代理,即vm.xx = vm.data.xx } // expose real self vm._self = vm; initLifecycle(vm);//初始化生命周期狀態(tài)變量,建立子父關系初始值,如$children,$parent. initEvents(vm);//?初始化事件 initRender(vm);//初始化render核心函數_$createElement和$slots $scopedSlots等 callHook(vm, "beforeCreate"); initInjections(vm); // resolve injections before data/props initState(vm);//利用數據劫持做響應式 initProvide(vm); //resolve provide after data/props callHook(vm, "created"); /* istanbul ignore if */ if ("development" !== "production" && config.performance && mark) { vm._name = formatComponentName(vm, false); mark(endTag); measure(((vm._name) + " init"), startTag, endTag); } if (vm.$options.el) { vm.$mount(vm.$options.el);//如果有el配置相則主動掛載。觸發(fā)之后的compile.render } };
介紹了大概的_init函數,我們繼續(xù)往下看程序的運行。完成了vue.component()之后。開始執(zhí)行new vue(),創(chuàng)建實例。
對照_init函數。我們知道它分別進行了對傳入參數的合并。初始化實例參數。創(chuàng)建響應的響應式。最后掛載:vm.$mount(vm.$options.el);
簡單說說掛載。好吧。我們還是往方法里面看,掛載的時候發(fā)生了什么:
// public mount method Vue$3.prototype.$mount = function ( el, hydrating ) { el = el && inBrowser ? query(el) : undefined; return mountComponent(this, el, hydrating) }; var mount = Vue$3.prototype.$mount//緩存mount,用來觸發(fā)render Vue$3.prototype.$mount = function (//核心mount用來構建render函數 el, hydrating ) { el = el && query(el); /* istanbul ignore if */ if (el === document.body || el === document.documentElement) { "development" !== "production" && warn( "Do not mount Vue to or- mount to normal elements instead."//檢測,排除不可掛載的元素 ); return this } var options = this.$options; // resolve template/el and convert to render function if (!options.render) { var template = options.template;//假如輸入的是template模版時。 if (template) { if (typeof template === "string") { if (template.charAt(0) === "#") { template = idToTemplate(template); /* istanbul ignore if */ if ("development" !== "production" && !template) { warn( ("Template element not found or is empty: " + (options.template)), this ); } } } else if (template.nodeType) { template = template.innerHTML;//輸入的是dom節(jié)點時 } else { { warn("invalid template option:" + template, this); } return this } } else if (el) { template = getOuterHTML(el);//如果是一個id,如此次初始化掛載的id=app,會取到id=app的html } if (template) { /* istanbul ignore if */ if ("development" !== "production" && config.performance && mark) { mark("compile"); } var ref = compileToFunctions(template, { shouldDecodeNewlines: shouldDecodeNewlines,//核心compile函數。用于生成render函數。這里不細說 delimiters: options.delimiters }, this); var render = ref.render; var staticRenderFns = ref.staticRenderFns; options.render = render;//掛載render到實例options中。待調用 options.staticRenderFns = staticRenderFns;//靜態(tài)的元素區(qū)分開。提升性能,后續(xù)虛擬dom樹比較時,不會比較靜態(tài)節(jié)點 /* istanbul ignore if */ if ("development" !== "production" && config.performance && mark) { mark("compile end"); measure(((this._name) + " compile"), "compile", "compile end"); } } } return mount.call(this, el, hydrating)//利用緩存的mount調用準備好的render }; $mount方法的核心其實就是準備好組件的render函數。這里最核心的一個方法就是:
var ref = compileToFunctions(template, { shouldDecodeNewlines: shouldDecodeNewlines,//核心compile函數。用于生成render函數。這里不細說 delimiters: options.delimiters }, this);compileToFunctions這個函數中主要做了兩件事:
1:對模版進行compile(按標簽解析,生成ast(抽象語法樹)
2:利用generate(ast, options),生成render函數語法我們來看看最后實例生成的render函數:
沒有錯就是這個樣子,很有感覺。生成的render函數保存在options中,等待調用
好吧。開始調用吧。
mount.call(this, el, hydrating)=》mountComponent(this, el, hydrating)=》updateComponent = function () { vm._update(vm._render(), hydrating); };=》vm._watcher = new Watcher(vm, updateComponent, noop);new Watcher中會主動調用updateComponent去touch依賴(給頁面中引用過的data中的變量假如監(jiān)聽)正式調用render函數。既然都說了。那就來看看render函數:
Vue.prototype._render = function () { var vm = this; var ref = vm.$options; var render = ref.render; var staticRenderFns = ref.staticRenderFns; var _parentVnode = ref._parentVnode; if (vm._isMounted) { // clone slot nodes on re-renders for (var key in vm.$slots) { vm.$slots[key] = cloneVNodes(vm.$slots[key]); } } vm.$scopedSlots = (_parentVnode && _parentVnode.data.scopedSlots) || emptyObject; if (staticRenderFns && !vm._staticTrees) { vm._staticTrees = []; } // set parent vnode. this allows render functions to have access // to the data on the placeholder node. vm.$vnode = _parentVnode; // render self var vnode; try { vnode = render.call(vm._renderProxy, vm.$createElement);//核心函數,調用render } catch (e) { handleError(e, vm, "render function"); // return error render result, // or previous vnode to prevent render error causing blank component /* istanbul ignore else */ { vnode = vm.$options.renderError ? vm.$options.renderError.call(vm._renderProxy, vm.$createElement, e) : vm._vnode; } } // return empty vnode in case the render function errored out if (!(vnode instanceof VNode)) { if ("development" !== "production" && Array.isArray(vnode)) { warn( "Multiple root nodes returned from render function. Render function " + "should return a single root node.", vm ); } vnode = createEmptyVNode(); } // set parent vnode.parent = _parentVnode; return vnode };_render()間接調用了vnode = render.call(vm._renderProxy, vm.$createElement);
然后結合render函數。看看發(fā)生了什么。vm.$createElement是核心的創(chuàng)建虛擬dom的函數。
繼續(xù)看看核心構建虛擬dom函數:
function createElement ( context, tag, data, children,//children是該元素下的所有子元素 normalizationType, alwaysNormalize ) { if (Array.isArray(data) || isPrimitive(data)) { normalizationType = children; children = data; data = undefined; } if (isTrue(alwaysNormalize)) { normalizationType = ALWAYS_NORMALIZE; } return _createElement(context, tag, data, children, normalizationType) } function _createElement ( context, tag, data, children, normalizationType ) { if (isDef(data) && isDef((data).__ob__)) { "development" !== "production" && warn( "Avoid using observed data object as vnode data: " + (JSON.stringify(data)) + " " + "Always create fresh vnode data objects in each render!", context ); return createEmptyVNode() } if (!tag) { // in case of component :is set to falsy value return createEmptyVNode() } // support single function children as default scoped slot if (Array.isArray(children) && typeof children[0] === "function") { data = data || {}; data.scopedSlots = { default: children[0] }; children.length = 0; } if (normalizationType === ALWAYS_NORMALIZE) { children = normalizeChildren(children); } else if (normalizationType === SIMPLE_NORMALIZE) { children = simpleNormalizeChildren(children); } var vnode, ns; if (typeof tag === "string") { var Ctor; ns = config.getTagNamespace(tag); if (config.isReservedTag(tag)) { // platform built-in elements vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context ); } else if (isDef(Ctor = resolveAsset(context.$options, "components", tag))) {假如是組件則從上下文中取出組件的構造相關參數 // component vnode = createComponent(Ctor, data, context, children, tag); } else { // unknown or unlisted namespaced elements // check at runtime because it may get assigned a namespace when its // parent normalizes children vnode = new VNode( tag, data, children, undefined, undefined, context ); } } else { // direct component options / constructor vnode = createComponent(tag, data, context, children); } if (vnode !== undefined) { if (ns) { applyNS(vnode, ns); } return vnode } else { return createEmptyVNode() } }這里其實我們不難看出vue在構造虛擬dom時。遞歸的去調用createElement去生成虛擬dom樹。當children是組件或者是普通元素時。做不同的處理。這里我們關注的是。當子元素是組建時。這里調用了
vnode = createComponent(tag, data, context, children);
細心的人可以在去看看這個函數做了什么。簡單來說這個函數將組件的構造參數取出來,放置在元素的componentOptions上。供后續(xù)創(chuàng)建真實dom時。標記該元素是組件。遞歸初始化。
跳過這些沉重的。我們直接看看我們的這個html生成的最終的虛擬dom長什么樣。如下:我們在來看看我們的my-component組件長什么樣子:
componentOptios上存著初始化組件需要的參數。
構建好虛擬dom后。vue進入update階段:
這個階段vue會判斷先前有無該元素。是否為第一次渲染。假如是第一次。那么直接創(chuàng)建。如果不是有先前的ovnode,則比較差異。最小化更新。看看具體函數:nction lifecycleMixin (Vue) { Vue.prototype._update = function (vnode, hydrating) { var vm = this; if (vm._isMounted) { callHook(vm, "beforeUpdate"); } var prevEl = vm.$el; var prevVnode = vm._vnode;//取出緩存的久的虛擬dom var prevActiveInstance = activeInstance; activeInstance = vm; vm._vnode = vnode;//緩存當前vnode,供下次更新使用 // Vue.prototype.__patch__ is injected in entry points // based on the rendering backend used. if (!prevVnode) {//假如第一次渲染。直接創(chuàng)建 // initial render vm.$el = vm.__patch__( vm.$el, vnode, hydrating, false /* removeOnly */, vm.$options._parentElm, vm.$options._refElm ); } else { // updates vm.$el = vm.__patch__(prevVnode, vnode);//更新時。會比較差異 } activeInstance = prevActiveInstance; // update __vue__ reference if (prevEl) { prevEl.__vue__ = null; } if (vm.$el) { vm.$el.__vue__ = vm; } // if parent is an HOC, update its $el as well if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) { vm.$parent.$el = vm.$el; } // updated hook is called by the scheduler to ensure that children are // updated in a parent"s updated hook. };__patch__函數我們就不細看了。算了看一下:
return function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) { if (isUndef(vnode)) { if (isDef(oldVnode)) { invokeDestroyHook(oldVnode); } return } var isInitialPatch = false; var insertedVnodeQueue = []; if (isUndef(oldVnode)) { // empty mount (likely as component), create new root element isInitialPatch = true;//假如第一次渲染。直接創(chuàng)建 createElm(vnode, insertedVnodeQueue, parentElm, refElm); } else { var isRealElement = isDef(oldVnode.nodeType); if (!isRealElement && sameVnode(oldVnode, vnode)) {//假如更新并且前后虛擬dom相似,這里相似有自己的一個算法。比如tag,key必需一致。才會去diff比較 // patch existing root node patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly); } else { if (isRealElement) { // mounting to a real element // check if this is server-rendered content and if we can perform // a successful hydration. if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) { oldVnode.removeAttribute(SSR_ATTR); hydrating = true; } if (isTrue(hydrating)) { if (hydrate(oldVnode, vnode, insertedVnodeQueue)) { invokeInsertHook(vnode, insertedVnodeQueue, true); return oldVnode } else { warn( "The client-side rendered virtual DOM tree is not matching " + "server-rendered content. This is likely caused by incorrect " + "HTML markup, for example nesting block-level elements inside " + ", or missing
. Bailing hydration and performing " + "full client-side render." ); } } // either not server-rendered, or hydration failed. // create an empty node and replace it oldVnode = emptyNodeAt(oldVnode); } // replacing existing element var oldElm = oldVnode.elm; var parentElm$1 = nodeOps.parentNode(oldElm); 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$1, nodeOps.nextSibling(oldElm) ); if (isDef(vnode.parent)) { // component root element replaced. // update parent placeholder node element, recursively var ancestor = vnode.parent; while (ancestor) { ancestor.elm = vnode.elm; ancestor = ancestor.parent; } if (isPatchable(vnode)) { for (var i = 0; i < cbs.create.length; ++i) { cbs.create[i](emptyNode, vnode.parent); } } } if (isDef(parentElm$1)) { removeVnodes(parentElm$1, [oldVnode], 0, 0); } else if (isDef(oldVnode.tag)) { invokeDestroyHook(oldVnode); } } } invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch); return vnode.elm }patch方法中核心的是createElm:看懂這個函數非常重要代碼如下
function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested) { vnode.isRootInsert = !nested; // for transition enter check if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {//根據之前保存的componentoptions來識別是否為組件。若是。則進這個邏輯 return } var data = vnode.data; var children = vnode.children; var tag = vnode.tag; if (isDef(tag)) { { if (data && data.pre) { inPre++; } if ( !inPre && !vnode.ns && !(config.ignoredElements.length && config.ignoredElements.indexOf(tag) > -1) && config.isUnknownElement(tag) ) { warn( "Unknown custom element: <" + tag + "> - did you " + "register the component correctly? For recursive components, " + "make sure to provide the "name" option.", vnode.context ); } } vnode.elm = vnode.ns ? nodeOps.createElementNS(vnode.ns, tag) : nodeOps.createElement(tag, vnode); setScope(vnode); /* istanbul ignore if */ { createChildren(vnode, children, insertedVnodeQueue); if (isDef(data)) { invokeCreateHooks(vnode, insertedVnodeQueue); } insert(parentElm, vnode.elm, refElm); } if ("development" !== "production" && data && data.pre) { inPre--; } } else if (isTrue(vnode.isComment)) { vnode.elm = nodeOps.createComment(vnode.text); insert(parentElm, vnode.elm, refElm); } else { vnode.elm = nodeOps.createTextNode(vnode.text); insert(parentElm, vnode.elm, refElm); } }我們這邊還是先關注自己的組件部分。當children是組件元素時,很顯然調用了createComponent(vnode, insertedVnodeQueue, parentElm, refElm);
var componentVNodeHooks = { init: function init ( vnode, hydrating, parentElm, refElm ) { if (!vnode.componentInstance || vnode.componentInstance._isDestroyed) { var child = vnode.componentInstance = createComponentInstanceForVnode( vnode, activeInstance, parentElm,//調用了組件內部的_init方法遞歸創(chuàng)建子組件。正式進入子組件的生命周期 refElm ); child.$mount(hydrating ? vnode.elm : undefined, hydrating);觸發(fā)子組件的掛載。出發(fā)子組件的編譯和render。又重新來一遍/直到子組件完全渲染好。再開始creelem下一個child } else if (vnode.data.keepAlive) { // kept-alive components, treat as a patch var mountedNode = vnode; // work around flow componentVNodeHooks.prepatch(mountedNode, mountedNode); } },這里就是遞歸創(chuàng)建子組件的核心部分.
總結: 第一次寫這個vue。失敗了。切模塊切的不夠細。組件機制感覺用了好多東西。這個面太大了。自己講的時候也不知道該細講還是。。。
總的來說:vue在創(chuàng)建虛擬dom的時候,如果元素是組件。則準備好組件的構造參數。包括模版和數據等等。組件中的元素如slot,和child放在組件元素的children下。供后面的內容分發(fā)用組件中的元素也是在父組件的作用域內編譯的。看—_render()函數就知道。然后在vue需要將虛擬dom變?yōu)檎鎸峝om時。遇到組件元素時。開始遞歸初始化。直到把組件compile,render構建完后。開始構建下一個元素。最后添加到真實id=app上。并且把舊的刪了。哈哈。隨便寫了
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/87027.html
相關文章
Vue 中 extend / component / mixins / extends 的區(qū)別
摘要:父組件在前,子組件在后。,,首先會在子組件里查找,如果沒有,才會沿著原型鏈向上,找父組件中對應的屬性。 在segmentfault上看到了一個問題vue.extend, vue.component 區(qū)別,突然有些方,好歹也寫了幾個小vue項目,自己都弄不清楚這些東西... new Vue()、component 首先我們來約定一個選項對象 baseOptions,后面的代碼會用到. ...
Vue源碼解讀(1)--src/core/index.js 入口文件
摘要:生產版本設為可以啟用檢查。只適用于開發(fā)模式和支持的瀏覽器上指定組件的渲染和觀察期間未捕獲錯誤的處理函數為的運行時警告賦予一個自定義處理函數。注意這只會在開發(fā)者環(huán)境下生效,在生產環(huán)境下它會被忽略。 Vue源碼主入口:src/core/index.js import Vue from ./instance/index // 引用Vue構造器 import { initGlobalAPI }...
(解析)vue源碼解讀
摘要:前言為什么要做源碼解讀我們新到一個環(huán)境,第一件事情就是熟悉環(huán)境熟悉項目,這個很考驗閱讀源碼的能力以及耐心。構造函數拉到最下面,導出的是一個構造函數。 前言 A: 為什么要做源碼解讀?Q: 我們新到一個環(huán)境,第一件事情就是熟悉環(huán)境熟悉項目,這個很考驗閱讀源碼的能力以及耐心。vue是個很好的庫,知名度高,對js的學習具有向上性,所以搞清楚邏輯是有好處的。A: 閱讀源碼的程度?Q: 我們完全...
來一打前端博客壓壓驚
前言 本文所有內容全部發(fā)布再個人博客主頁 https://github.com/muwoo/blogs歡迎訂閱。不過最近因為事情比較多,有一段時間沒有更新了,后面打算繼續(xù)不斷學習更新,歡迎小伙伴一起溝通交流~ 最近更新 前端單測的那些事 基于virtual dom 的canvas渲染 js Event loop 機制簡介 axios 核心源碼實現原理 JS 數據類型、賦值、深拷貝和淺拷貝 j...
發(fā)表評論
0條評論
1treeS
男|高級講師
TA的文章
閱讀更多
什么是 Python 編程語言?
閱讀 3669·2021-11-24 09:39
C語言 位運算符詳解 (使用二進制實例深入學習理解位運算符使用原理)
閱讀 1276·2021-09-30 09:48
Bitwarden免費密碼管理軟件實現跨平臺在線密碼管理(云端密碼存儲)
閱讀 3258·2021-09-09 11:51
RAKsmart:九月秒殺服務器$30/月起,洛杉磯/圣何塞/香港/日本站群特價,全場VPS五折
閱讀 2883·2021-09-08 10:41
用CSS3實現鐘表效果
閱讀 1329·2019-08-30 14:06
CSS詳細解讀定位
閱讀 2798·2019-08-30 14:01
css-浮動
閱讀 874·2019-08-29 17:11
前端面試題(4)JavaScript
閱讀 3169·2019-08-29 15:37
<閱讀需要支付1元查看