Javascript の高性能アニメーションとページ レンダリング

黄舟
リリース: 2017-02-25 13:34:04
オリジナル
1362 人が閲覧しました


setTimeout も setInterval もありません

アニメーションを実装するために setTimeout または setInterval を使用する必要がある場合、その理由は、アニメーションを正確に制御する必要があるということだけです。しかし、少なくとも現時点では、高度なブラウザ、さらにはモバイル ブラウザが十分に普及しているため、アニメーションを実装するときにより効率的な方法を使用する理由が得られると思います。

何が効率的か

このページでは、すべてのフレーム変更がシステム (GPU または CPU) によって描画されると説明されています。ただし、この種の描画は PC ゲームの描画とは異なり、その最大描画周波数は (グラフィックス カードではなく) モニターのリフレッシュ周波数によって制限されるため、ほとんどの場合、最高の描画周波数は 1 秒あたり 60 フレームしかありません。フレーム/秒)(以下、fpsと呼びます)、ディスプレイの60Hzに相当します。毎日のページ パフォーマンス テストでは 60fps が最も理想的な状態であり、60fps は重要な指標でもあり、近いほど優れています。 Chrome のデバッグ ツールの中には、現在のフレーム番号を測定するために使用されるツールが多数あります。

次の作業では、これらのツールを使用してページのパフォーマンスをリアルタイムで表示します。

60fps ということは、各フレームの描画に 16.7 ミリ秒 (1000 / 60) しかないことを意味するため、モチベーションでもありプレッシャーでもあります。 setTimeout または setInterval (以下、総称してタイマーと呼びます) を使用して描画を制御すると、問題が発生します。

まず第一に、タイマーの遅延計算は十分に正確ではありません。遅延の計算はブラウザの内蔵クロックに依存し、クロックの精度はクロックの更新頻度 (タイマー解像度) に依存します。 IE8 および以前の IE バージョンの更新間隔は 15.6 ミリ秒です。設定した setTimeout 遅延が 16.7 ミリ秒であると仮定すると、遅延がトリガーされる前に 15.6 ミリ秒ごとに 2 回更新する必要があります。これは、15.6 x 2 – 16.7 = 14.5 ミリ秒の原因不明の遅延も意味します。

            16.7ms
DELAY: |------------|

CLOCK: |----------|----------|
          15.6ms    15.6ms
ログイン後にコピー

そのため、setTimeout の遅延を 0ms に設定しても、すぐにはトリガーされません。 Chrome および IE9+ ブラウザの現在の更新頻度は 4 ミリ秒です (ラップトップを使用しており、電源モードではなくバッテリーを使用している場合、リソースを節約するために、ブラウザは更新頻度を同じシステム時間に切り替えます。つまり、更新頻度が低くなります)頻繁に更新されます)。

一歩下がって、タイマーの分解能が 16.7 ミリ秒に達する可能性がある場合、非同期キューの問題にも直面します。非同期関係のため、setTimeout のコールバック関数はすぐには実行されませんが、待機キューに追加する必要があります。しかし、問題は、遅延トリガーを待っている間に実行する必要がある新しい同期スクリプトがある場合、その同期スクリプトはタイマーのコールバック後にキューに入れられず、次のコードのようにすぐに実行されることです。

function runForSeconds(s) {
    var start = +new Date();
    while (start + s * 1000 > (+new Date())) {}
}

document.body.addEventListener("click", function () {
    runForSeconds(10);
}, false);

setTimeout(function () {
    console.log("Done!");
}, 1000 * 3);
ログイン後にコピー

待っている場合 トリガーの遅延の 3 秒の間に誰かがボディをクリックしましたが、3 秒が完了してもコールバックは時間通りにトリガーされますか?もちろんそうではありません。同期関数は常に非同期関数より優先されます。

等待3秒延迟 |    1s    |    2s    |    3s    |--->console.log("Done!");

经过2秒     |----1s----|----2s----|          |--->console.log("Done!");

点击body后

以为是这样:|----1s----|----2s----|----3s----|--->console.log("Done!")--->|------------------10s----------------|

其实是这样:|----1s----|----2s----|------------------10s----------------|--->console.log("Done!");
ログイン後にコピー

John Resign には、タイマーのパフォーマンスと精度に関する 3 つの記事があります: 1.JavaScript 時間の精度、2.タイマー パフォーマンスの分析、3.JavaScript の仕組みタイマーが機能します。この記事から、さまざまなプラットフォームのブラウザーやオペレーティング システムでのタイマーに関するいくつかの問題がわかります。

一歩下がって、タイマーの解像度が 16.7 ミリ秒に達すると仮定し、非同期関数が遅延しないと仮定すると、タイマーによって制御されるアニメーションはまだ満足のいくものではありません。これについては次のセクションで説明します。

垂直同期の問題

別の定数 60、つまり画面のリフレッシュ レート 60Hz を紹介させてください。

60Hzと60fpsの関係は何ですか?関係ない。 fps は GPU が画像をレンダリングする周波数を表し、Hz はモニターが画面を更新する周波数を表します。静止画像の場合、この画像の fps は 0 フレーム/秒であると言えますが、この時点の画面のリフレッシュ レートが 0 Hz であるとは決して言えません。つまり、変更によってリフレッシュ レートは変化しません。画像コンテンツの。ゲームであれブラウザであれ、フレームドロップについて話すとき、それは GPU が画像をレンダリングする頻度が低下することを意味します。たとえば、30 fps、さらには 20 fps まで低下しますが、視覚持続の原理により、私たちが見ている画像は依然として動き、一貫性があります。

前のセクションからの続きで、各タイマーが同期機能によって遅延または干渉されず、時間を 16ms に短縮することもできると仮定すると、何が起こります:

(画像をクリックすると拡大します)

22秒でフレーム損失が発生しました

遅延時間が短縮されると、損失フレームの数はさらに多くなります:

実際の状況は上記の想像よりもはるかに複雑になります。 60Hz 画面でのフレーム損失の問題を解決するために固定遅延を与えることができたとしても、他のリフレッシュ レートのモニターではどうすればよいでしょうか。異なるデバイス、または同じデバイスであっても画面のリフレッシュ レートが異なることを知っておく必要があります。バッテリーの状態が異なります。

以上同时还忽略了屏幕刷新画面的时间成本。问题产生于GPU渲染画面的频率和屏幕刷新频率的不一致:如果GPU渲染出一帧画面的时间比显示器刷新一张画面的时间要短(更快),那么当显示器还没有刷新完一张图片时,GPU渲染出的另一张图片已经送达并覆盖了前一张,导致屏幕上画面的撕裂,也就是是上半部分是前一张图片,下半部分是后一张图片:

PC游戏中解决这个问题的方法是开启垂直同步(v-sync),也就是让GPU妥协,GPU渲染图片必须在屏幕两次刷新之间,且必须等待屏幕发出的垂直同步信号。但这样同样也是要付出代价的:降低了GPU的输出频率,也就降低了画面的帧数。以至于你在玩需要高帧数运行的游戏时(比如竞速、第一人称射击)感觉到“顿卡”,因为掉帧。

requestAnimationFrame

在这里不谈requestAnimationFrame(以下简称rAF)用法,具体请参考MDN:Window.requestAnimationFrame()。我们来具体谈谈rAF所解决的问题。

从上一节我们可以总结出实现平滑动画的两个因素

  1. 时机(Frame Timing): 新的一帧准备好的时机

  2. 成本(Frame Budget): 渲染新的一帧需要多长的时间

这个Native API把我们从纠结于多久刷新的一次的困境中解救出来(其实rAF也不关心距离下次屏幕刷新页面还需要多久)。当我们调用这个函数的时候,我们告诉它需要做两件事: 1. 我们需要新的一帧;2.当你渲染新的一帧时需要执行我传给你的回调函数

那么它解决了我们上面描述的第一个问题,产生新的一帧的时机。

那么第二个问题呢。不,它无能为力。比如可以对比下面两个页面:

  1. DEMO

  2. DEMO-FIXED

对比两个页面的源码,你会发现只有一处不同:

// animation loop
function update(timestamp) {
    for(var m = 0; m < movers.length; m++) {
        // DEMO 版本
        //movers[m].style.left = ((Math.sin(movers[m].offsetTop + timestamp/1000)+1) * 500) + &#39;px&#39;;

        // FIXED 版本
        movers[m].style.left = ((Math.sin(m + timestamp/1000)+1) * 500) + &#39;px&#39;;
        }
    rAF(update);
};
rAF(update);
ログイン後にコピー

DEMO版本之所以慢的原因是,在修改每一个物体的left值时,会请求这个物体的offsetTop值。这是一个非常耗时的reflow操作(具体还有哪些耗时的reflow操作可以参考这篇: How (not) to trigger a layout in WebKit)。这一点从Chrome调试工具中可以看出来(截图中的某些功能需要在Chrome canary版本中才可启用)

未矫正的版本

可见大部分时间都花在了rendering上,而矫正之后的版本:

rendering时间大大减少了

但如果你的回调函数耗时真的很严重,rAF还是可以为你做一些什么的。比如当它发现无法维持60fps的频率时,它会把频率降低到30fps,至少能够保持帧数的稳定,保持动画的连贯。

使用rAF推迟代码

没有什么是万能的,面对上面的情况,我们需要对代码进行组织和优化。

看看下面这样一段代码:

function jank(second) {
    var start = +new Date();
    while (start + second * 1000 > (+new Date())) {}
}

p.style.backgroundColor = "red";

// some long run task
jank(5);

p.style.backgroundColor = "blue";
ログイン後にコピー

无论在任何的浏览器中运行上面的代码,你都不会看到p变为红色,页面通常会在假死5秒,然后容器变为蓝色。这是因为浏览器的始终只有一个线程在运行(可以这么理解,因为js引擎与UI引擎互斥)。虽然你告诉浏览器此时p背景颜色应该为红色,但是它此时还在执行脚本,无法调用UI线程。

有了这个前提,我们接下来看这段代码:

var p = document.getElementById("foo");

var currentWidth = p.innerWidth; 
p.style.backgroundColor = "blue";

// do some "long running" task, like sorting data
ログイン後にコピー

这个时候我们不仅仅需要更新背景颜色,还需要获取容器的宽度。可以想象它的执行顺序如下:

当我们请求innerWidth一类的属性时,浏览器会以为我们马上需要,于是它会立即更新容器的样式(通常浏览器会攒着一批,等待时机一次性的repaint,以便节省性能),并把计算的结果告诉我们。这通常是性能消耗量大的工作。

但如果我们并非立即需要得到结果呢?

上面的代码有两处不足,

  1. 更新背景颜色的代码过于提前,根据前一个例子,我们知道,即使在这里告知了浏览器我需要更新背景颜色,浏览器至少也要等到js运行完毕才能调用UI线程;

  2. 假设后面部分的long runing代码会启动一些异步代码,比如setTimeout或者Ajax请求又或者web-worker,那应该尽早为妙。

综上所述,如果我们不是那么迫切的需要知道innerWidth,我们可以使用rAF推迟这部分代码的发生:

requestAnimationFrame(function(){
    var el = document.getElementById("foo");

    var currentWidth = el.innerWidth;
    el.style.backgroundColor = "blue";

    // ...
});

// do some "long running" task, like sorting data
ログイン後にコピー

可见即使我们在这里没有使用到动画,但仍然可以使用rAF优化我们的代码。执行的顺序会变成:

在这里rAF的用法变成了:把代码推迟到下一帧执行。

有时候我们需要把代码推迟的更远,比如这个样子:

再比如我们想要一个效果分两步执行:1.p的display变为block;2. p的top值缩短移动到某处。如果这两项操作都放入同一帧中的话,浏览器会同时把这两项更改应用于容器,在同一帧内。于是我们需要两帧把这两项操作区分开来:

requestAnimationFrame(function(){
   el.style.display = "block";
   requestAnimationFrame(function(){
      // fire off a CSS transition on its `top` property
      el.style.top = "300px";
   });
});
ログイン後にコピー

这样的写法好像有些不太讲究,Kyle Simpson有一个开源项目h5ive,它把上面的用法封装了起来,并且提供了API。实现起来非常简单,摘一段代码瞧瞧:

function qID(){
    var id;
    do {
        id = Math.floor(Math.random() * 1E9);
    } while (id in q_ids);
    return id;
}

function queue(cb) {
    var qid = qID();

    q_ids[qid] = rAF(function(){
        delete q_ids[qid];
        cb.apply(publicAPI,arguments);
    });

    return qid;
}

function queueAfter(cb) {
    var qid;

    qid = queue(function(){
        // do our own rAF call here because we want to re-use the same `qid` for both frames
        q_ids[qid] = rAF(function(){
            delete q_ids[qid];
            cb.apply(publicAPI,arguments);
        });
    });

    return qid;
}
ログイン後にコピー

使用方法:

// 插入下一帧
id1 = aFrame.queue(function(){
    text = document.createTextNode("##");
    body.appendChild(text);
});

// 插入下下一帧
id2 = aFrame.queueAfter(function(){
    text = document.createTextNode("!!");
    body.appendChild(text);
});
ログイン後にコピー

使用rAF解耦代码

先从一个2011年twitter遇到的bug说起。

当时twitter加入了一个新功能:“无限滚动”。也就是当页面滚至底部的时候,去加载更多的twitter:

$(window).bind(&#39;scroll&#39;, function () {
    if (nearBottomOfPage()) {
        // load more tweets ...
    }
});
ログイン後にコピー

但是在这个功能上线之后,发现了一个严重的bug:经过几次滚动到最底部之后,滚动就会变得奇慢无比。

经过排查发现,原来是一条语句引起的:$details.find(“.details-pane-outer”);

这还不是真正的罪魁祸首,真正的原因是因为他们将使用的jQuery类库从1.4.2升级到了1.4.4版。而这jQuery其中一个重要的升级是把Sizzle的上下文选择器全部替换为了querySelectorAll。但是这个接口原实现使用的是getElementsByClassName。虽然querySelectorAll在大部分情况下性能还是不错的。但在通过Class名称选择元素这一项是占了下风。有两个对比测试可以看出来:1.querySelectorAll v getElementsByClassName 2.jQuery Simple Selector

通过这个bug,John Resig给出了一条(实际上是两条,但是今天只取与我们话题有关的)非常重要的建议

It’s a very, very, bad idea to attach handlers to the window scroll event.

他想表达的意思是,像scroll,resize这一类的事件会非常频繁的触发,如果把太多的代码放进这一类的回调函数中,会延迟页面的滚动,甚至造成无法响应。所以应该把这一类代码分离出来,放在一个timer中,有间隔的去检查是否滚动,再做适当的处理。比如如下代码:

var didScroll = false;

$(window).scroll(function() {
    didScroll = true;
});

setInterval(function() {
    if ( didScroll ) {
        didScroll = false;
        // Check your page position and then
        // Load in more results
    }
}, 250)
ログイン後にコピー

这样的作法类似于Nicholas将需要长时间运算的循环分解为“片”来进行运算:

// 具体可以参考他写的《javascript高级程序设计》
// 也可以参考他的这篇博客: http://www.php.cn/
function chunk(array, process, context){
    var items = array.concat();   //clone the array
    setTimeout(function(){
        var item = items.shift();
        process.call(context, item);

        if (items.length > 0){
            setTimeout(arguments.callee, 100);
        }
    }, 100);
}
ログイン後にコピー

原理其实是一样的,为了优化性能、为了防止浏览器假死,将需要长时间运行的代码分解为小段执行,能够使浏览器有时间响应其他的请求。

回到rAF上来,其实rAF也可以完成相同的功能。比如最初的滚动代码是这样:

function onScroll() {
    update();
}

function update() {

    // assume domElements has been declared
    for(var i = 0; i < domElements.length; i++) {

        // read offset of DOM elements
        // to determine visibility - a reflow

        // then apply some CSS classes
        // to the visible items - a repaint

    }
}

window.addEventListener(&#39;scroll&#39;, onScroll, false);
ログイン後にコピー

这是很典型的反例:每一次滚动都需要遍历所有元素,而且每一次遍历都会引起reflow和repaint。接下来我们要做的事情就是把这些费时的代码从update中解耦出来。

首先我们仍然需要给scroll事件添加回调函数,用于记录滚动的情况,以方便其他函数的查询:

var latestKnownScrollY = 0;

function onScroll() {
    latestKnownScrollY = window.scrollY;
}
ログイン後にコピー

接下来把分离出来的repaint或者reflow操作全部放入一个update函数中,并且使用rAF进行调用:

function update() {
    requestAnimationFrame(update);

    var currentScrollY = latestKnownScrollY;

    // read offset of DOM elements
    // and compare to the currentScrollY value
    // then apply some CSS classes
    // to the visible items
}

// kick off
requestAnimationFrame(update);
ログイン後にコピー

其实解耦的目的已经达到了,但还需要做一些优化,比如不能让update无限执行下去,需要设标志位来控制它的执行:

var latestKnownScrollY = 0,
    ticking = false;

function onScroll() {
    latestKnownScrollY = window.scrollY;
    requestTick();
} 

function requestTick() {
    if(!ticking) {
        requestAnimationFrame(update);
    }
    ticking = true;
}
ログイン後にコピー

并且我们始终只需要一个rAF实例的存在,也不允许无限次的update下去,于是我们还需要一个出口:

function update() {
    // reset the tick so we can
    // capture the next onScroll
    ticking = false;

    var currentScrollY = latestKnownScrollY;

    // read offset of DOM elements
    // and compare to the currentScrollY value
    // then apply some CSS classes
    // to the visible items
}

// kick off - no longer needed! Woo.
// update();
ログイン後にコピー

理解Layer

Kyle Simpson说:

Rule of thumb: don’t do in JS what you can do in CSS.

如以上所说,即使使用rAF,还是会有诸多的不便。我们还有一个选择是使用css动画:虽然浏览器中UI线程与js线程是互斥,但这一点对css动画不成立。

在这里不聊css动画的用法。css动画运用的是什么原理来提升浏览器性能的。

首先我们看看淘宝首页的焦点图:

我想提出一个问题,为什么明明可以使用translate 2d去实现的动画,它要用3d去实现呢?

私はタオバオの従業員ではありませんが、最初にこれを行う理由は、translate3d ハックを使用するためだと思います。簡単に言うと、 -webkit-transform: translationZ(0); または -webkit-transform: translation3d(0,0,0); 属性を要素に追加すると、GPU を使用してレンダリングするようにブラウザーに指示することになります。通常の CPU レンダリングと比較して速度とパフォーマンスが向上します。 (これを行うと Chrome でハードウェア アクセラレーションが有効になると確信していますが、他のプラットフォームでの保証はありません。私が入手した情報の限りでは、Firefox や Safari などのほとんどのブラウザでも適用可能です)。

しかし、この記述は実際には正確ではありません。少なくとも現在のバージョンの Chrome では、これはハッキングではありません。デフォルトでは、すべての Web ページがレンダリング時に GPU を経由するためです。それで、これはまだ必要ですか?持っている。原理を理解する前に、まずレイヤーの概念を理解する必要があります。

html はブラウザで DOM ツリーに変換され、DOM ツリーの各ノードは 1 つ以上の RenderLayer に対応する RenderObject に変換されます。ブラウザレンダリングのプロセスは次のとおりです:

  1. DOM を取得し、複数のレイヤー (RenderLayer) に分割します

  2. 各レイヤーをラスタライズし、ビットマップ内で個別に描画します

  3. ビットマップが GPU にアップロードされますテクスチャとして

  4. 複数のレイヤーを組み合わせて、最終的な画面イメージ(究極のレイヤー)を生成します。

これはゲームの 3D レンダリングに似ていますが、このキャラクターの皮膚は別の画像から「貼り付け」られ、「つなぎ合わせ」られています。 Web ページにはこれよりもう 1 つのステップがあり、最終的な Web ページは複数のビットマップ レイヤーで構成されますが、私たちが見ているのは単なるコピーであり、最終的に存在するレイヤーは 1 つだけです。もちろん、フラッシュなど合成できないレイヤーもあります。 iQiyi (http://www.php.cn/) の再生ページを例にとると、Chrome のレイヤー パネル (デフォルトでは有効になっていないため、手動でオンにする必要があります) を使用して、ページ上のすべてのレイヤーを表示できます。

ページが次のレイヤーで構成されていることがわかります:

OK、それでは疑問が生じます。

今、コンテナのスタイルを変更し (アニメーションのステップとして見ることができます)、最悪の場合、その長さと幅を変更するとします。なぜ長さと幅を変更することが最悪のケースになるのでしょうか?通常、オブジェクトのスタイルを変更するには、次の 4 つの手順が必要です:

属性を変更すると、ブラウザはコンテナのスタイルを再計算します。たとえば、コンテナのサイズや位置を変更した場合 (リフロー)。の場合、最初に影響を受けるのはコンテナのサイズと位置 (関連する親ノードの隣接ノードの位置などにも影響します) で、次にブラウザはコンテナを再描画する必要がありますが、背景色のみを変更した場合は、コンテナのサイズに関連する属性がないため、最初のステップで位置を計算する時間が節約されます。つまり、属性変更をウォーターフォール チャートの早い段階 (上位にあるもの) で開始すると、影響が大きくなり、効率が低くなります。リフローと再ペイントでは、影響を受けるすべてのノードが配置されているレイヤーのビットマップが再描画され、上記のプロセスが繰り返し実行されるため、効率が低下します。

コストを最小限に抑えるために、もちろん、合成レイヤーのステップのみを残すことが最善です。コンテナのスタイルを変更すると、それ自体にのみ影響があり、再描画する必要がない場合は、GPU のテクスチャの属性を変更してスタイルを直接変更する方がよいのではないでしょうか。これは、独自のレイヤーがある場合にはもちろん実現可能です

これは、上記のハードウェア アクセラレーション ハックの原理でもあり、CSS アニメーションの原理でもあります。レイヤーをほとんどの要素と共有するのではなく、要素に対して独自のレイヤーを作成します。ページ。

独自のレイヤーを作成できる要素は何ですか? Chrome では、次の条件のうち少なくとも 1 つを満たす必要があります:

  • レイヤーには 3D または遠近変換 CSS プロパティ (3D 要素のプロパティ) がある

  • レイヤーは、高速ビデオ デコードを使用して

  • Layer は、3D コンテキストまたは高速化された 2D コンテキストを持つ 要素によって使用されます (canvas 要素と 3D を有効にします)

  • Layer は、複合プラグインに使用されます (プラグ-in (フラッシュなど)

  • レイヤーは、不透明度に CSS アニメーションを使用するか、アニメーション化された Webkit 変換 (CSS アニメーション) を使用します

  • レイヤーは、高速化された CSS フィルター (CSS フィルター) を使用します

  • 合成されたレイヤー子孫には、クリップやリフレクション (独立したレイヤーである子孫要素を持つ) など、合成レイヤー ツリーに存在する必要がある情報が含まれています

  • レイヤーには、合成レイヤーを持つ、より低い Z インデックスを持つ兄弟があります (言い換えると、レイヤーは合成レイヤーの上にレンダリングされます) (要素の隣接する要素は独立したレイヤーです)

明らかに、先ほど見た再生ページのフラッシュと、translate3d スタイルがオンになっているフォーカス画像は、以上の条件。

同时你也可以勾选Chrome开发工具中的rendering选显卡下的Show composited layer borders 选项。页面上的layer便会加以边框区别开来。为了验证我们的想法,看下面这样一段代码:

<html>
<head>
  <style type="text/css">
  p {
      -webkit-animation-duration: 5s;
      -webkit-animation-name: slide;
      -webkit-animation-iteration-count: infinite;
      -webkit-animation-direction: alternate;
      width: 200px;
      height: 200px;
      margin: 100px;
      background-color: skyblue;
  }
  @-webkit-keyframes slide {
      from {
          -webkit-transform: rotate(0deg);
      }
      to {
          -webkit-transform: rotate(120deg);
      }
  }
  </style>
</head>
<body>
  <p id="foo">I am a strange root.</p>
</body>
</html>
ログイン後にコピー

运行时的timeline截图如下:

可见元素有自己的layer,并且在动画的过程中没有触发reflow和repaint。

最后再看看淘宝首页,不仅仅只有焦点图才拥有了独立的layer:

但太多的layer也未必是一件好事情,有兴趣的同学可以看一看这篇文章:Jank Busting Apple’s Home Page。看一看在苹果首页太多layer时出现的问题。

 以上就是Javascript高性能动画与页面渲染的内容,更多相关内容请关注PHP中文网(www.php.cn)!


ソース:php.cn
このウェブサイトの声明
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
人気のチュートリアル
詳細>
最新のダウンロード
詳細>
ウェブエフェクト
公式サイト
サイト素材
フロントエンドテンプレート
私たちについて 免責事項 Sitemap
PHP中国語ウェブサイト:福祉オンライン PHP トレーニング,PHP 学習者の迅速な成長を支援します!