애니메이션을 구현하기 위해 setTimeout 또는 setInterval을 사용해야 하는 이유는 애니메이션에 대한 정밀한 제어가 필요하기 때문일 수 있습니다. 하지만 적어도 현시점에서는 애니메이션을 구현할 때 좀 더 효율적인 방법을 사용해야 할 이유를 제공할 만큼 고급 브라우저, 심지어 모바일 브라우저도 인기가 있다고 생각합니다.
프레임이 바뀔 때마다 시스템(GPU 또는 CPU)이 페이지를 그립니다. 그러나 이러한 종류의 그리기는 PC 게임의 그리기와 다릅니다. 최대 그리기 빈도는 모니터(그래픽 카드가 아님)의 새로 고침 빈도에 의해 제한되므로 대부분의 경우 가장 높은 그리기 빈도는 초당 60프레임에 불과합니다( 초당 프레임 수)(이하 fps라고 함)는 디스플레이의 60Hz에 해당합니다. 60fps는 가장 이상적인 상태입니다. 일일 페이지 성능 테스트에서도 60fps는 중요한 지표이며 가까울수록 좋습니다. Chrome의 디버깅 도구 중에는 현재 프레임 번호를 측정하는 데 사용되는 도구가 많이 있습니다.
다음 작업에서는 이러한 도구를 사용하여 페이지 성능을 확인하겠습니다. 실시간.
60fps는 동기 부여이자 압박감입니다. 각 프레임을 그리는 데 16.7밀리초(1000 / 60)밖에 남지 않았기 때문입니다. setTimeout이나 setInterval(이하 총칭하여 타이머)을 사용하여 그리기를 제어하면 문제가 발생합니다.
우선 타이머의 지연 계산이 정확하지 않습니다. 지연 계산은 브라우저에 내장된 시계에 따라 달라지며, 시계의 정확도는 시계 업데이트 빈도(타이머 해상도)에 따라 달라집니다. IE8 및 이전 IE 버전의 업데이트 간격은 15.6밀리초입니다. 설정한 setTimeout 지연이 16.7ms라고 가정하면 지연이 트리거되기 전에 15.6밀리초씩 두 번 업데이트되어야 합니다. 이는 또한 15.6 x 2 – 16.7 = 14.5밀리초의 설명할 수 없는 지연을 의미합니다.
16.7ms DELAY: |------------| CLOCK: |----------|----------| 15.6ms 15.6ms
따라서 setTimeout의 지연을 0ms로 설정하더라도 즉시 실행되지는 않습니다. Chrome 및 IE9+ 브라우저의 현재 업데이트 빈도는 4ms입니다(노트북을 사용하고 전원 공급 모드 대신 배터리를 사용하는 경우 리소스를 절약하기 위해 브라우저는 업데이트 빈도를 동일한 시스템 시간으로 전환합니다. 즉, 더 적은 시간을 의미합니다). 빈번한 업데이트).
한발 뒤로 물러서서 타이머 해상도가 16.7ms에 도달할 수 있다면 비동기 대기열 문제에도 직면하게 됩니다. 비동기 관계로 인해 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은 타이머 성능 및 정확성에 관한 세 가지 기사를 작성했습니다. 1. JavaScript 시간의 정확성, 2. 타이머 성능 분석 3.JavaScript 타이머 작동 방식. 기사에서 다양한 플랫폼 브라우저 및 운영 체제에서 Timer의 몇 가지 문제를 볼 수 있습니다.
한발 물러서서 타이머 해상도가 16.7ms에 도달할 수 있고 비동기 기능이 지연되지 않는다고 가정하면 타이머로 제어되는 애니메이션은 여전히 만족스럽지 않습니다. 이것이 다음 섹션에서 이야기할 내용입니다.
또 다른 상수 60, 즉 화면 새로 고침 빈도 60Hz를 소개하겠습니다.
60Hz와 60fps의 관계는 무엇인가요? 관계가 없습니다. fps는 GPU가 이미지를 렌더링하는 빈도를 나타내고, Hz는 모니터가 화면을 새로 고치는 빈도를 나타냅니다. 정적 사진의 경우 이 사진의 fps가 0프레임/초라고 말할 수 있지만 이때 화면의 새로 고침 빈도가 0Hz라고 결코 말할 수 없습니다. 이는 새로 고침 빈도가 변화에 따라 변하지 않는다는 의미입니다. 이미지 콘텐츠의 게임이든 브라우저이든 프레임 드롭에 관해 이야기할 때 이는 GPU가 그림을 덜 자주 렌더링한다는 의미입니다. 예를 들어, 30fps 또는 심지어 20fps로 떨어지지만 시각 지속성의 원리로 인해 우리가 보는 그림은 여전히 움직이고 일관성이 있습니다.
이전 섹션에 이어 각 타이머가 지연되지 않으며 동기화 기능의 방해도 받지 않으며 시간을 16ms로 단축할 수도 있다고 가정하면 어떻게 될까요?
(이미지를 클릭하시면 확대됩니다)
22초에 프레임 손실 발생
지연 시간을 단축하면 손실되는 프레임 수가 더 많아집니다. :
실제 상황은 위에서 상상한 것보다 훨씬 더 복잡할 것입니다. 60Hz 화면의 프레임 손실 문제를 해결하기 위해 고정된 지연을 제공할 수 있다고 하더라도 다른 새로 고침 빈도를 가진 모니터에서는 어떻게 해야 합니까? 배터리 상태가 다릅니다.
以上同时还忽略了屏幕刷新画面的时间成本。问题产生于GPU渲染画面的频率和屏幕刷新频率的不一致:如果GPU渲染出一帧画面的时间比显示器刷新一张画面的时间要短(更快),那么当显示器还没有刷新完一张图片时,GPU渲染出的另一张图片已经送达并覆盖了前一张,导致屏幕上画面的撕裂,也就是是上半部分是前一张图片,下半部分是后一张图片:
PC游戏中解决这个问题的方法是开启垂直同步(v-sync),也就是让GPU妥协,GPU渲染图片必须在屏幕两次刷新之间,且必须等待屏幕发出的垂直同步信号。但这样同样也是要付出代价的:降低了GPU的输出频率,也就降低了画面的帧数。以至于你在玩需要高帧数运行的游戏时(比如竞速、第一人称射击)感觉到“顿卡”,因为掉帧。
在这里不谈requestAnimationFrame(以下简称rAF)用法,具体请参考MDN:Window.requestAnimationFrame()。我们来具体谈谈rAF所解决的问题。
从上一节我们可以总结出实现平滑动画的两个因素
时机(Frame Timing): 新的一帧准备好的时机
成本(Frame Budget): 渲染新的一帧需要多长的时间
这个Native API把我们从纠结于多久刷新的一次的困境中解救出来(其实rAF也不关心距离下次屏幕刷新页面还需要多久)。当我们调用这个函数的时候,我们告诉它需要做两件事: 1. 我们需要新的一帧;2.当你渲染新的一帧时需要执行我传给你的回调函数
那么它解决了我们上面描述的第一个问题,产生新的一帧的时机。
那么第二个问题呢。不,它无能为力。比如可以对比下面两个页面:
DEMO
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) + 'px'; // FIXED 版本 movers[m].style.left = ((Math.sin(m + timestamp/1000)+1) * 500) + 'px'; } 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,至少能够保持帧数的稳定,保持动画的连贯。
没有什么是万能的,面对上面的情况,我们需要对代码进行组织和优化。
看看下面这样一段代码:
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,以便节省性能),并把计算的结果告诉我们。这通常是性能消耗量大的工作。
但如果我们并非立即需要得到结果呢?
上面的代码有两处不足,
更新背景颜色的代码过于提前,根据前一个例子,我们知道,即使在这里告知了浏览器我需要更新背景颜色,浏览器至少也要等到js运行完毕才能调用UI线程;
假设后面部分的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); });
先从一个2011年twitter遇到的bug说起。
当时twitter加入了一个新功能:“无限滚动”。也就是当页面滚至底部的时候,去加载更多的twitter:
$(window).bind('scroll', 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('scroll', 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();
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去实现呢?
저는 타오바오 직원은 아니지만, 먼저 이렇게 하는 이유는 Translator3d 해킹을 사용하기 위한 것이라고 추측합니다. 간단히 말해서 -webkit-transform:transformZ(0); 또는 -webkit-transform:translate3d(0,0,0); 속성을 요소에 추가하면 브라우저에 GPU를 사용하여 렌더링하도록 지시하는 것입니다. 요소 레이어를 사용하여 일반 CPU 렌더링에 비해 속도와 성능이 향상되었습니다. (이렇게 하면 Chrome에서 하드웨어 가속이 활성화될 것이라고 확신하지만 다른 플랫폼에서는 보장할 수 없습니다. 내가 얻은 정보에 따르면 Firefox 및 Safari와 같은 대부분의 브라우저에도 적용 가능합니다.)
그러나 이 진술은 실제로 정확하지 않습니다. 적어도 현재 버전의 Chrome에서는 해킹이 아닙니다. 기본적으로 모든 웹 페이지는 렌더링 시 GPU를 통과하기 때문입니다. 그렇다면 이것이 여전히 필요한가? 가지다. 원리를 이해하기 전에 먼저 레이어의 개념을 이해해야 합니다.
html은 브라우저에서 DOM 트리로 변환되고, DOM 트리의 각 노드는 RenderObject로 변환됩니다. 여러 RenderObject가 하나 이상의 RenderLayer에 해당할 수 있습니다. 브라우저 렌더링 과정은 다음과 같습니다.
DOM을 가져와서 여러 레이어로 분할(RenderLayer)
각 레이어를 그리드화하고, 비트맵을 독립적으로 그립니다.
이러한 비트맵을 GPU에 텍스처로 업로드
여러 레이어를 합성하여 최종 화면 이미지(최종 레이어)를 생성합니다.
이것은 게임의 3D 렌더링과 유사합니다. 입체적인 캐릭터로 보이지만 이 캐릭터의 피부는 다른 사진에서 "붙여넣어" "맞추어" 있습니다. 의. 웹 페이지에는 이보다 한 단계가 더 있으며, 최종 웹 페이지는 여러 개의 비트맵 레이어로 구성되어 있지만 우리가 보는 것은 단지 복사본일 뿐이며 궁극적으로 레이어는 하나뿐입니다. 물론 플래시 등 일부 레이어는 결합할 수 없습니다. iQiyi(http://www.php.cn/)의 재생 페이지를 예로 들면, Chrome의 레이어 패널(기본적으로 활성화되어 있지 않으며 수동으로 켜야 함)을 사용하여 페이지의 모든 레이어를 볼 수 있습니다.
페이지가 다음 레이어로 구성되어 있음을 알 수 있습니다.
자, 그러면 질문이 나옵니다.
이제 컨테이너의 스타일을 변경하고(애니메이션 단계로 볼 수 있음) 최악의 경우 길이와 너비를 변경한다고 가정해 보겠습니다. 길이와 너비를 변경하는 것이 가장 좋은 이유는 무엇입니까? 정말 나쁜 상황입니다. 일반적으로 객체의 스타일을 변경하려면 다음 네 단계가 필요합니다.
크기를 변경하는 경우와 같이 속성을 변경하면 브라우저가 컨테이너의 스타일을 다시 계산하게 됩니다. 컨테이너 또는 위치(리플로우)의 경우 가장 먼저 영향을 받는 것은 컨테이너의 크기와 위치입니다(관련 상위 노드의 인접 노드 위치에도 영향을 미침). 그런 다음 브라우저는 컨테이너를 다시 그려야 합니다. ; 하지만 컨테이너의 배경색과 컨테이너의 크기와 관련이 없는 기타 속성만 변경하면 첫 번째 단계에서 위치를 계산하는 시간이 절약됩니다. 즉, 폭포형 차트에서 속성 변경이 더 일찍(위로 갈수록) 시작되면 영향은 더 커지고 효율성은 낮아집니다. 리플로우와 리페인팅을 수행하게 되면 영향을 받은 모든 노드가 위치한 레이어의 비트맵이 다시 그려지게 되고, 위의 과정이 반복적으로 실행되어 효율성이 떨어지게 됩니다.
비용을 최소화하기 위해서는 당연히 합성 레이어 단계만 남겨 두는 것이 가장 좋습니다. 컨테이너의 스타일을 변경하면 컨테이너 자체에만 영향을 미치고 다시 그릴 필요가 없다고 가정하면 GPU에서 텍스처의 속성을 변경하여 직접 스타일을 변경하는 것이 더 좋지 않을까요? 물론 이것은 자신만의 레이어가 있다면 달성 가능합니다.
이것은 위의 하드웨어 가속 해킹의 원리이기도 하며 CSS 애니메이션의 원리이기도 합니다. 대부분의 레이어가 아닌 요소에 대한 자체 레이어를 생성하세요. 페이지의 요소 중 요소는 레이어를 공유합니다.
어떤 종류의 요소가 자체 레이어를 만들 수 있나요? Chrome에서는 다음 조건 중 하나 이상을 충족해야 합니다.
레이어에 3D 또는 원근 변환 CSS 속성(3D 요소가 있는 속성)이 있습니다.
레이어는 가속 비디오 디코딩을 사용하는 39000f942b2545a5315c57fa3276f220 요소에 사용됩니다(비디오 태그 및 가속 비디오 디코딩 사용)
레이어는 3D 컨텍스트가 있는 5ba626b379994d53f7acf72a64f9b697 요소에 사용됩니다. 또는 가속화된 2D 컨텍스트(캔버스 요소 및 3D 지원)
레이어가 합성 플러그인(플래시와 같은 플러그인)에 사용됩니다
레이어는 불투명도를 위해 CSS 애니메이션을 사용하거나 애니메이션 웹킷 변환(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)!