ホームページ > ウェブフロントエンド > jsチュートリアル > jQuery のセレクター エンジンである Sizzle の分析

jQuery のセレクター エンジンである Sizzle の分析

不言
リリース: 2018-07-14 09:30:51
オリジナル
1980 人が閲覧しました

この記事では主に jQuery のセレクター エンジン Sizzle の分析を紹介します。必要な方は参考にしてください。Sizzle のバージョン番号は次のとおりです。

2.3.3ブラウザでネイティブにサポートされている要素クエリメソッド:

メソッド名getElementByIdgetElementsByName querySelectorquerySelectorAll

Sizzle では、パフォーマンス上の理由から、クエリには JS のネイティブ メソッドを使用することが優先されます。上記のメソッドのうち、使用されない querySelector メソッドを除き、その他はすべて Sizzle で使用されます。 querySelector方法没有被用到,其它都在Sizzle中有使用。

对于不可以使用原生方法直接获取结果的case,Sizzle就需要进行词法分析,分解这个复杂的CSS选择器,然后再逐项查询过滤,获取最终符合查询条件的元素。

有以下几个点是为了提高这种低级别查询的速度:

  • 从右至左: 传统的选择器是从左至右,比如对于选择器#box .cls a,它的查询过程是先找到id=box的元素,然后在这个元素后代节点里查找class中包含cls元素;找到后,再查找这个元素下的所有a元素。查找完成后再回到上一层,继续查找下一个.cls元素,如此往复,直至完成。这样的做法有一个问题,就是有很多不符合条件元素,在查找也会被遍历到。而对于从右向左的顺序,它是先找到所有a的元素,然后在根据剩下的选择器#box .cls,筛选出符合这个条件的a元素。这样一来,等于是限定了查询范围,相对而言速度当然会更快。但是需要明确的一点是,并不是所有的选择器都适合这种从右至左的方式查询。也并不是所有的从右至左查询都比从左至右快,只是它覆盖了绝大多数的查询情况。

  • 限定种子集合: 如果只有一组选择器,也就是不存在逗号分隔查询条件的情况;则先查找最末级的节点,在最末级的节点集合中筛选;

  • 限定查询范围: 如果父级节点只是一个ID且不包含其它限制条件,则将查询范围缩小到父级节点;#box a

  • 缓存特定数据 : 主要分三类,tokenCache, compileCache, classCache

我们对Sizzle的查询分为两类:

  1. 简易流程(没有位置伪类)

  2. 带位置伪类的查询

简易流程

简易流程在进行查询时,遵循 从右至左的流程。

梳理一下简易流程

Sizzle流程图(简易版)

简易流程忽略的东西主要是和位置伪类相关的处理逻辑,比如:nth-child之类的

词法分析

词法分析,将字符串的选择器,解析成一系列的TOKEN。

首先明确一下TOKEN的概念,TOKEN可以看做最小的原子,不可再拆分。在CSS选择器中,TOKEN的表现形式一般是TAG、ID、CLASS、ATTR等。一个复杂的CSS选择器,经过词法分析后,会生成一系列的TOKEN,然后根据这些Token进行最终的查询和筛选。

下面举个例子说明一下词法分析的过程。对于字符串#box .cls a的解析:

/**
 * 下面是Sizzle中词法解析方法 tokennize 的核心代码 1670 ~ 1681 行
 * soFar = '#box .cls a'
 * Expr.filter 是Sizzle进行元素过滤的方法集合
 * Object.getOwnPropertyNames(Expr.filter) //  ["TAG", "CLASS", "ATTR", "CHILD", "PSEUDO", "ID"]
*/
for ( type in Expr.filter ) {
    // 拿当前的选择字符串soFar 取匹配filter的类型,如果能匹配到,则将当前的匹配对象取出,并当做一个Token存储起来
    // matchExpr中存储一些列正则,这些正则用于验证当前选择字符串是否满足某一token语法
    if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] ||
        (match = preFilters[ type ]( match ))) ) {
        matched = match.shift();
        tokens.push({
            value: matched,
            type: type,
            matches: match
        });

        // 截取掉匹配到选择字符串,继续匹配剩余的字符串(继续匹配是通过这段代码外围的while(soFar)循环实现的)
        // matchExpr中存储的正则都是元字符“^”开头,验证字符串是否以‘xxx’开头;这也就是说, 词法分析的过程是从字符串开始位置,从左至右,一下一下地剥离出token
        soFar = soFar.slice( matched.length );
    }
}
ログイン後にコピー

经过上述的解析过程后,#box .cls a会被解析成如下形式的数组:
Sizzle: tokens

编译函数

编译函数的流程很简单,首先根据selector去匹配器的缓存中查找对应的匹配器。

如果之前进行过相同selector的查询并且缓存还在(因为Sizzle换粗数量有限,如果超过数量限制,最早的缓存会被删掉),则直接返回当前缓存的匹配器。

如果缓存中找不到,则通过matcherFromTokens()matcherFromGroupMatchers() 方法生成终极匹配器,并将终极匹配器缓存。

根据tokens生成匹配器(matcherFromTokens)

这一步是根据词法分析产出的tokens,生成matchers(匹配器)。
在Sizzle中,对应的方法是matcherFromTokens

打个预防针,这个方法读起来,很费神呐。

在Sizzle源码(sizzle.js文件)中第 1705 ~ 1765 行
ネイティブ メソッドを使用して結果を直接取得できない場合、Sizzle は字句解析を実行し、複雑な CSS セレクターを分解し、項目ごとにクエリとフィルターを実行して、最終的にクエリ条件を満たす要素を取得する必要があります。

この低レベルのクエリの速度を改善するには、いくつかのポイントがあります:

右から左へ

: 従来のセレクターは左から右です。たとえば、セレクター #box .cls a の場合、そのクエリ プロセスでは、まず id=box を持つ要素が検索され、次にこの要素の子孫ノードが検索されます。クラス cls 要素を含む要素を見つけたら、この要素の下にあるすべての a 要素を検索します。検索が完了したら、前のレベルに戻って次の .cls 要素の検索を続け、完了するまで同様に検索します。このアプローチの問題の 1 つは、条件を満たさず、検索中に横断される要素が多数存在することです。右から左の順序の場合、最初に a のすべての要素が検索され、次に残りのセレクター #box .cls に基づいてこの条件を満たす要素が除外されます。 > a 要素。このように、クエリのスコープは制限されており、速度は当然相対的に速くなります。ただし、明確にしておきたいのは、すべてのセレクターがこの右から左へのクエリに適しているわけではないということです。すべての右から左へのクエリが左から右へのクエリより高速であるわけではありませんが、クエリ状況の大部分をカバーします。

  • 制限されたシード セット🎜: セレクターのセットが 1 つしかない場合、つまりカンマ区切りのクエリ条件がない場合は、最終レベルのノードが最初に検索され、最後のレベルのノードが検索されます。 -level ノード セット フィルター; 🎜
  • 🎜🎜クエリ範囲を制限する🎜: 親ノードが単なる ID であり、他の制限が含まれていない場合、クエリ範囲は親ノードに狭められます。 >#box a;🎜
  • 🎜🎜キャッシュ固有データ🎜: 主に 3 つのカテゴリに分かれています、🎜tokenCache🎜、🎜compileCache🎜、🎜classCache🎜;🎜
  • 🎜Sizzle のクエリ分析 2 つのカテゴリがあります: 🎜
    1. 🎜単純な処理 (位置疑似クラスなし)🎜
    2. 🎜位置を伴うクエリpseudo-class🎜

    簡単なプロセス

    🎜簡単なプロセス お問い合わせの際は、🎜右から左🎜のプロセスに従ってください。 🎜🎜簡単な処理を整理してみましょう🎜🎜シズルフローチャート(簡易版)🎜🎜 簡単な処理で無視されるのは、主にnth-childなどの位置擬似クラスに関連する処理ロジックです🎜

    字句分析

    🎜 字句分析。文字列セレクターを一連の TOKEN に解析します。 🎜🎜まず、TOKEN の概念を明確にしましょう。TOKEN は最小の原子とみなされ、分割することはできません。 CSS セレクターでは、TOKEN の表現形式は一般的に TAG、ID、CLASS、ATTR などです。複雑な CSS セレクターは、字句解析後に一連のトークンを生成し、これらのトークンに基づいて最終的なクエリとフィルター処理を実行します。 🎜🎜以下は字句解析のプロセスを説明する例です。文字列 #box .cls a の解析: 🎜
    function matcherFromTokens( tokens ) {
        var checkContext, matcher, j,
            len = tokens.length,
            leadingRelative = Expr.relative[ tokens[0].type ],
            implicitRelative = leadingRelative || Expr.relative[" "],
            i = leadingRelative ? 1 : 0,
    
            // The foundational matcher ensures that elements are reachable from top-level context(s)
            matchContext = addCombinator( function( elem ) {
                return elem === checkContext;
            }, implicitRelative, true ),
            matchAnyContext = addCombinator( function( elem ) {
                return indexOf( checkContext, elem ) > -1;
            }, implicitRelative, true ),
            matchers = [ function( elem, context, xml ) {
                var ret = ( !leadingRelative && ( xml || context !== outermostContext ) ) || (
                    (checkContext = context).nodeType ?
                        matchContext( elem, context, xml ) :
                        matchAnyContext( elem, context, xml ) );
                // Avoid hanging onto element (issue #299)
                checkContext = null;
                return ret;
            } ];
            
        // 上面的都是变量声明
    
        // 这个for循环就是根据tokens 生成matchers 的过程
        for ( ; i < len; i++ ) {
    
            // 如果碰到 祖先/兄弟 关系(&#39;>', ' ', '+', '~'),则需要合并之前的matchers;
            if ( (matcher = Expr.relative[ tokens[i].type ]) ) {
                matchers = [ addCombinator(elementMatcher( matchers ), matcher) ];
            } else {
                matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches );
                matchers.push( matcher );
            }
        }
    
        // 将所有的matchers 拼合到一起 返回一个匹配器,
        // 所有的matcher返回值都是布尔值,只要有一个条件不满足,则当前元素不符合,排除掉
        return elementMatcher( matchers );
    }
    ログイン後にコピー
    🎜 上記の解析プロセスの後、#box .cls a は次の形式の配列に解析されます: 🎜 Sizzle: トークン🎜

    関数のコンパイル

    🎜 関数のコンパイルのプロセスは非常に簡単です。まず、selector を使用して、マッチャーのキャッシュ内で対応するマッチャーを見つけます。 🎜🎜同じ selector クエリが以前に実行されており、キャッシュがまだ存在する場合 (シズル置換の数には制限があるため、数制限を超えると最も古いキャッシュが削除されます)、現在キャッシュされている一致がデバイスに直接返されます。 🎜🎜キャッシュで見つからない場合は、matcherFromTokens() メソッドと matcherFromGroupMatchers() メソッドを通じて最終マッチャーが生成され、最終マッチャーがキャッシュされます。 🎜

    トークンに基づいてマッチャーを生成する (matcherFromTokens)

    🎜 このステップでは、字句解析によって生成されたトークンに基づいてマッチャー (マッチャー) を生成します。 🎜Sizzle では、対応するメソッドは matcherFromTokens です。 🎜🎜ワクチン接種を受けましょう、この方法は読むのが非常に面倒です。 🎜🎜Sizzle ソース コード (sizzle.js ファイル) の 1705 ~ 1765 行 はわずか 60 行ですが、多くのファクトリ メソッドが含まれています ( just 戻り値が Function 型であるメソッドを指します)。 🎜このメソッドのプロセスを簡略化してみましょう (疑似クラスセレクターの処理を削除します)🎜
    [
        [
            { "value": "p", "type": "TAG", "matches": ["p"] }, 
            { "value": ".cls", "type": "CLASS", "matches": ["cls"] }, 
            { "value": " ", "type": " " }, 
            { "value": "input", "type": "TAG", "matches": ["input"] }, 
            { "value": "[type=\"text\"]", "type": "ATTR", "matches": ["type", "=", "text"]}
        ]
    ]
    ログイン後にコピー
    ログイン後にコピー
    🎜🎜質問: 🎜先祖/兄弟関係 ('>'、' '、'+'、'~') が発生した場合はなぜですか、以前のマッチャーをマージする必要がありますか? 🎜🎜🎜答え:🎜目的は必ずしもマージすることではなく、(祖先/兄弟関係['>'、' '、'+'、'~']を満たす)現在のノードに関連するノードを見つけることです。マッチャーは、関連するノードがマッチャーを満たすかどうかを検証します。 「検証」ステップでは、以前のマッチャーをマージする必要はありませんが、マージされた構造はより明確になります。例: 🎜
    我们需要买汽车,现在有两个汽车品牌A、B。A下面有四种车型:a1,a2,a3,a4;B下面有两种车型:b1,b2。那么我们可以的买到所有车就是
    [a1,a2,a3,a4,b1,b2]。但是我们也可以这么写{A:[a1,a2,a3,a4],B:[b1,b2]}。这两种写法都可以表示我们可以买到车型。只是第二种相对前者,更清晰列出了车型所属品牌关系。

    同理,在合并后,我们就知道这个合并后的matcher就是为了验证当前的节点的关联节点。

    生成终极匹配器(matcherFromGroupMatchers)

    主要是返回一个匿名函数,在这个函数中,利用matchersFromToken方法生成的匹配器,去验证种子集合seed,筛选出符合条件的集合。
    先确定种子集合,然后在拿这些种子跟匹配器逐个匹配。在匹配的过程中,从右向左逐个token匹配,只要有一个环节不满条件,则跳出当前匹配流程,继续进行下一个种子节点的匹配过程。

    通过这样的一个过程,从而筛选出满足条件的DOM节点,返回给select方法。

    查询过程demo

    用一个典型的查询,来说明Sizzle的查询过程。

    p.cls  input[type="text"] 为例:

    解析出的tokens:

    [
        [
            { "value": "p", "type": "TAG", "matches": ["p"] }, 
            { "value": ".cls", "type": "CLASS", "matches": ["cls"] }, 
            { "value": " ", "type": " " }, 
            { "value": "input", "type": "TAG", "matches": ["input"] }, 
            { "value": "[type=\"text\"]", "type": "ATTR", "matches": ["type", "=", "text"]}
        ]
    ]
    ログイン後にコピー
    ログイン後にコピー

    首先这个选择器 会筛选出所有的<input>作为种子集合seed,然后在这个集合中寻找符合条件的节点。
    在寻找种子节点的过程中,删掉了token中的第四条{ "value": "input", "type": "TAG", "matches": ["input"] }

    那么会根据剩下的tokens生成匹配器

    • matcherByTag('p')

    • matcherByClass('.cls')

    碰见父子关系' ',将前面的生成的两个matcher合并生成一个新的

    • matcher:

      • matcherByTag('p'),

      • matcherByClass('.cls')

    这个matcher 是通过addCombinator()方法生成的匿名函数,这个matcher会先根据 父子关系parentNode,取得当前种子的parentNode, 然后再验证是否满足前面的两个匹配器。

    碰见第四条 属性选择器,生成

    • matcherByAttr('[type="text"]')

    至此,根据tokens已经生成所有的matchers。

    终极匹配器

    • matcher:

      • matcherByTag('p')

      • matcherByClass('.cls')

    • matcherByAttr('[type="text"]')

    matcherFromTokens()方法中的最后一行,还有一步操作,将所有的matchers通过elementMatcher()合并成一个matcher。
    elementMatcher这个方法就是将所有的匹配方法,通过while循环都执行一遍,如果碰到不满足条件的,就直接挑出while循环。
    有一点需要说明的就是: elementMatcher方法中的while循环是倒序执行的,即从matchers最后一个matcher开始执行匹配规则。对应上面的这个例子就是,最开始执行的匹配器是matcherByAttr('[type="text"]')。 这样一来,就过滤出了所有不满足type="text"<input>的元素。然后执行下一个匹配条件,

    Question: Sizzle中使用了大量闭包函数,有什么作用?出于什么考虑的?
    Answer:闭包函数的作用,是为了根据selector动态生成匹配器,并将这个匹配器缓存(cached)。因为使用闭包,匹配器得以保存在内存中,这为缓存机制提供了支持。
    这么做的主要目的是提高查询性能,通过常驻内存的匹配器避免再次消耗大量资源进行词法分析和匹配器生成。以空间换时间,提高查询速度。

    Question: matcherFromTokens中, 对每个tokens生成匹配器列表时,为什么会有一个初始化的方法?
    Answer: 这个初始化的方法是用来验证元素是否属于当前context

    Question: matcherFromGroupMatchers的作用?
    Answer: 返回一个终极匹配器,并让编译函数缓存这个终极匹配器。 在这个终极匹配器中,会将获取到的种子元素集合与匹配器进行比对,筛选出符合条件的元素。

    TODO: 编译机制也许是Sizzle为了做缓存以便提高性能而做出的选择??
    是的,详细答案待补充~~~

    TODO: outermostContext的作用
    细节问题,还有待研究~~~


    带位置伪类的查询流程

    带位置伪类的查询是 由左至右

    用选择器.mark li.limark:first.limark2 a span举例。

    在根据tokens生成匹配器(matcherFromTokens)之前的过程,跟简易查询没有任何区别。
    不同的地方就在matcherFromTokens()方法中。位置伪类不同于简易查询的是,它会根据位置伪类将选择器分成三个部分。对应上例就是如下

    • .mark li.limark : 位置伪类之前的选择器;

    • :first : 位置伪类本身;

    • .limark2: 跟位置伪类本身相关的选择器,

    • a span:位置伪类之后的选择器;

    位置伪类的查询思路,是先进行位置伪类之前的查询.mark li.limark,这个查询过程当然也是利用之前讲过的简易流程(Sizzle(selector))。查询完成后,再根据位置伪类进行过滤,留下满足位置伪类的节点。如果存在第三个条件,则利用第三个条件,再进行一次过滤。然后再利用这些满足位置伪类节点作为context,进行位置伪类之后选择器 a span的查询。

    上例选择器中只存在一个位置伪类;如果存在多个,则从左至右,会形成一个一个的层级,逐个层级进行查询。

    下面是对应的是matcherFromTokens()方法中对位置伪类处理。

    // 这个matcherFromTokens中这个for循环,之前讲过了,但是 有个地方我们跳过没讲
    for ( ; i < len; i++ ) {
            if ( (matcher = Expr.relative[ tokens[i].type ]) ) {
                matchers = [ addCombinator(elementMatcher( matchers ), matcher) ];
            } else {
                matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches );
    
                // Return special upon seeing a positional matcher
                // 这个就是处理位置伪类的逻辑
                if ( matcher[ expando ] ) {
                    // Find the next relative operator (if any) for proper handling
                    j = ++i;
                    for ( ; j < len; j++ ) { // 寻找下一个关系节点位置,并用j记录下来
                        if ( Expr.relative[ tokens[j].type ] ) {
                            break;
                        }
                    }
                    return setMatcher(// setMatcher 是生成位置伪类查询的工厂方法
                        i > 1 && elementMatcher( matchers ), // 位置伪类之前的matcher
                        i > 1 && toSelector(
                            // If the preceding token was a descendant combinator, insert an implicit any-element `*`
                            tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" })
                        ).replace( rtrim, "$1" ), // 位置伪类之前的selector
                        matcher, // 位置伪类本身的matcher
                        i < j && matcherFromTokens( tokens.slice( i, j ) ), // 位置伪类本身的filter
                        j < len && matcherFromTokens( (tokens = tokens.slice( j )) ), // 位置伪类之后的matcher
                        j < len && toSelector( tokens ) // 位置伪类之后的selector
                    );
                }
                matchers.push( matcher );
            }
        }
    ログイン後にコピー

    setMatcher()方法的源码,在这里生成最终的matcher, return给compile()方法。

    //第1个参数,preFilter,前置过滤器,相当于伪类token之前`.mark li.limark`的过滤器matcher
    //第2个参数,selector,伪类之前的selector (`.mark li.limark`)
    //第3个参数,matcher,    当前位置伪类的过滤器matcher `:first`
    //第4个参数,postFilter,伪类之后的过滤器 `.limark2`
    //第5个参数,postFinder,后置搜索器,相当于在前边过滤出来的集合里边再搜索剩下的规则的一个搜索器 ` a span`的matcher
    //第6个参数,postSelector,后置搜索器对应的选择器字符串,相当于` a span`
    function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) {
        //TODO: setMatcher 会把这俩货在搞一次setMatcher, 还不太懂
        if ( postFilter && !postFilter[ expando ] ) {
            postFilter = setMatcher( postFilter );
        }
        if ( postFinder && !postFinder[ expando ] ) {
            postFinder = setMatcher( postFinder, postSelector );
        }
        
        return markFunction(function( seed, results, context, xml ) {
            var temp, i, elem,
                preMap = [],
                postMap = [],
                preexisting = results.length,
    
                // Get initial elements from seed or context
                elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [] ),
    
                // Prefilter to get matcher input, preserving a map for seed-results synchronization
                matcherIn = preFilter && ( seed || !selector ) ?
                    condense( elems, preMap, preFilter, context, xml ) :
                    elems,
    
                matcherOut = matcher ?
                    // If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results,
                    postFinder || ( seed ? preFilter : preexisting || postFilter ) ?
    
                        // ...intermediate processing is necessary
                        [] :
    
                        // ...otherwise use results directly
                        results :
                    matcherIn;
    
            // Find primary matches
            if ( matcher ) {
                // 这个就是 匹配位置伪类的 逻辑, 将符合位置伪类的节点剔出来
                matcher( matcherIn, matcherOut, context, xml );
            }
    
            // Apply postFilter
            if ( postFilter ) {
                temp = condense( matcherOut, postMap );
                postFilter( temp, [], context, xml );
    
                // Un-match failing elements by moving them back to matcherIn
                i = temp.length;
                while ( i-- ) {
                    if ( (elem = temp[i]) ) {
                        matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem);
                    }
                }
            }
    
            if ( seed ) {
                if ( postFinder || preFilter ) {
                    if ( postFinder ) {
                        // Get the final matcherOut by condensing this intermediate into postFinder contexts
                        temp = [];
                        i = matcherOut.length;
                        while ( i-- ) {
                            if ( (elem = matcherOut[i]) ) {
                                // Restore matcherIn since elem is not yet a final match
                                temp.push( (matcherIn[i] = elem) );
                            }
                        }
                        postFinder( null, (matcherOut = []), temp, xml );
                    }
    
                    // Move matched elements from seed to results to keep them synchronized
                    i = matcherOut.length;
                    while ( i-- ) {
                        if ( (elem = matcherOut[i]) &&
                            (temp = postFinder ? indexOf( seed, elem ) : preMap[i]) > -1 ) {
    
                            seed[temp] = !(results[temp] = elem);
                        }
                    }
                }
    
            // Add elements to results, through postFinder if defined
            } else {
                matcherOut = condense(
                    matcherOut === results ?
                        matcherOut.splice( preexisting, matcherOut.length ) :
                        matcherOut
                );
                if ( postFinder ) {
                    postFinder( null, results, matcherOut, xml );
                } else {
                    push.apply( results, matcherOut );
                }
            }
        });
    }
    ログイン後にコピー

    以上就是本文的全部内容,希望对大家的学习有所帮助,更多相关内容请关注PHP中文网!

    相关推荐:

    Debounce函数和Throttle函数的实现原理

    メソッドの説明 互換性の説明
    要素 ID に基づくクエリ要素 IE6+、Firefox 2 +、クロム4 +、safari 3.1+ +、Firefox 3 以降、Chrome 4 以降、Safari 3.1 以降
    要素名属性に基づいて要素をクエリ IE10 以降 (IE10 未満ではサポートされないか不完全)、FireFox23 以降、Chrome 29 以降、Safari 6 以降
    セレクターに基づいたクエリ要素 IE9+ (IE8 は部分的にサポートされています)、Firefox 3.5+、Chrome 4+、Safari 3.1+
    セレクターに基づいたクエリ要素 IE9+ (IE8 は部分的にサポート)、Firefox 3.5 以降、Chrome 4 以降、Safari 3.1 以降

    以上がjQuery のセレクター エンジンである Sizzle の分析の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

    関連ラベル:
    ソース:php.cn
    このウェブサイトの声明
    この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
    最新の問題
    人気のチュートリアル
    詳細>
    最新のダウンロード
    詳細>
    ウェブエフェクト
    公式サイト
    サイト素材
    フロントエンドテンプレート