ホームページ > ウェブフロントエンド > htmlチュートリアル > 新しいセラー センター Bigpipe 実践 (2)_html/css_WEB-ITnose

新しいセラー センター Bigpipe 実践 (2)_html/css_WEB-ITnose

WBOY
リリース: 2016-06-24 11:22:48
オリジナル
1363 人が閲覧しました

前回、新バージョンの Seller Center Bigpipe 実践編 (1) を通じて Bigpipe 実装のアイデアと原則を説明して以来、あっという間に春がやって来ました。そして、冬の冷たい風と向き合い始めてから、徐々に暖かくなってきた今までの練習の全過程。私はそこから多くのことを得たので、それを皆さんと共有したいと思います。コードが多いのでコンパイラはご自身でご用意ください。

中心的な問題

すべてのテクノロジーは問題を解決するために作成または使用されます。そのため、開始する前に、解決すべき問題を見てください:

  • 最初の画面モジュールを同期的にロードし、サーバー側の各モジュールがコンテンツを生成します。並行して、クライアントがコンテンツをレンダリングします。最後のコンテンツがいつ生成されたかによって異なります。ここでの問題点は 同期 です。複数のモジュールを同期する必要があるため、ブラウザが待たされることは避けられません。ブラウザが待つということは、ユーザーも待つことを意味します。
  • そこで、ローリング非同期読み込みモジュールを採用しました。最初にページフレームが表示され、いくつかの菊の花が回転して装飾され、その後、非同期リクエストによって最初の画面モジュールが 1 つずつ表示されます。取得したものはすべてクライアント上でレンダリングして表示できますが、それでも遅延感はあります。ここでの問題点は リクエスト で、各モジュールにはもう 1 つのリクエストが必要で、これにも時間がかかります。
  • Facebook のエンジニアは次のように考えていますか: 1 回のリクエストで、各ファースト スクリーン モジュール サーバーは生成されたコンテンツを並行して処理し、生成されたコンテンツはレンダリングのためにクライアントに直接送信され、ユーザーはすぐにコンテンツを見ることができます。は良いゲームです レイ~
  • 実際、Bigpipe のアイデアはマイクロプロセッサーの組立ラインからインスピレーションを得たものです

技術的な進歩

セラーセンターの本体も機能的にモジュール化されており、これは Facebook が直面する問題と一致しています。別の言い方をすると、中心的な質問は、リクエスト リンクを通じて、サーバーが動的コンテンツをチャンクに分けてクライアントに送信し、コンテンツの送信が完了してリクエストが完了するまで、リアルタイムのレンダリングと表示を行うことができるかということです。

概念

  • 技術的なポイント: HTTP プロトコルのチャンク送信 (HTTP 1.1 で提供) 概念エントリ
  • HTTP メッセージ (要求メッセージまたは応答メッセージ) の Transfer-Encoding ヘッダーの値がチャンク化されている場合、メッセージは本体は不特定の数のブロックで構成され、サイズ 0 の最後のブロックで終わります。
  • このメカニズムは、Web コンテンツを複数のコンテンツ ブロックに分割し、サーバーとブラウザーがパイプラインを確立し、さまざまな段階でその動作を管理します。

実装

ブロック単位でのデータ送信を実装する方法は、言語ごとに異なる方法があります。

PHP のメソッド

<html><head>    <title>php chunked</title></head><body>    <?php sleep(1); ?>    <div id="moduleA"><?php echo 'moduleA' ?></div>    <?php ob_flush(); flush(); ?>        <?php sleep(3); ?>    <div id="moduleB"><?php echo 'moduleB' ?></div>    <?php ob_flush(); flush(); ?>        <?php sleep(2); ?>    <div id="moduleC"><?php echo 'moduleC' ?></div>    <?php ob_flush(); flush(); ?></body></html>
ログイン後にコピー

  • PHP は、ob_flush と flash を使用してページをチャンクで更新し、ブラウザーにキャッシュして、コンテンツのチャンク レンダリングを実現します。
  • PHP はスレッドをサポートしていないため、サーバーはマルチスレッドを使用して複数のモジュールのコンテンツを並行して処理できません。
  • PHP には同時実行ソリューションもありますが、ここでは説明しません。興味がある場合は、詳しく学習してください。

Javaのやり方

  • Javaにも単純なページのブロック転送を実現するflushに似た機能があります。
  • Java はマルチスレッドであるため、各モジュールの内容を並列処理することが簡単です。

フラッシュに関する考え

  • Yahoo 34 パフォーマンス最適化ルールでは、フラッシュのタイミングがヘッドの後であると述べており、ブラウザはヘッドで導入された CSS/js を最初にダウンロードできるようにすることができます。
  • コンテンツを分割してブラウザにフラッシュします。フラッシュされるコンテンツの優先順位はユーザーにとって重要です。たとえば、Yahoo は以前、検索ボックスのフラッシュが中核的な機能だったので優先していました。
  • フラッシュのコンテンツ サイズは効果的に分割する必要があり、大きなコンテンツは小さなコンテンツに分割できます。

Node.js の実装

Bigpipe の実装における PHP と Java の長所と短所を比較すると、Node.js で幸せを見つけるのは簡単です。

  • Node.js の非同期の性質により、並列問題を簡単に処理できます。
  • ビュー レイヤーには包括的な制御があり、サーバー側のデータ処理とクライアント側のレンダリングに自然な利点があります。
  • Node.js の HTTP インターフェイスは、他の方法では使用が難しい HTTP プロトコルの多くの機能をサポートするように設計されています。

HelloWorld に戻る

var http = require('http');http.createServer(function (request, response){  response.writeHead(200, {'Content-Type': 'text/html'});  response.write('hello');  response.write(' world ');  response.write('~ ');  response.end();}).listen(8080, "127.0.0.1");
ログイン後にコピー

  • HTTP ヘッダー Transfer-Encoding=chunked 、すごいですね!
  • response.endを示さずにresponse.writeデータだけの場合、レスポンスは終了せず、ブラウザはリクエストを保持します。 response.end が呼び出される前に、response.write を通じてコン​​テンツをフラッシュできます。
  • HelloWorld から Bigpipe Node.js の実装が始まり、少し興奮しました。

完了ポイント

layout.html

<!DOCTYPE html><html><head>	<!-- css and js tags -->    <link rel="stylesheet" href="index.css" />    <script> function renderFlushCon(selector, html) { document.querySelector(selector).innerHTML = html; } </script></head><body>    <div id="A"></div>    <div id="B"></div>    <div id="C"></div>
ログイン後にコピー

  • headにはロードしたいアセットが含まれています
  • 出力ページフレーム、A/B/Cモジュールのプレースホルダー

var http = require('http');var fs = require('fs');http.createServer(function(request, response) {  response.writeHead(200, { 'Content-Type': 'text/html' });  // flush layout and assets  var layoutHtml = fs.readFileSync(__dirname + "/layout.html").toString();  response.write(layoutHtml);    // fetch data and render  response.write('<script>renderFlushCon("#A","moduleA");</script>');  response.write('<script>renderFlushCon("#C","moduleC");</script>');  response.write('<script>renderFlushCon("#B","moduleB");</script>');    // close body and html tags  response.write('</body></html>');  // finish the response  response.end();}).listen(8080, "127.0.0.1");
ログイン後にコピー

ページ出力:

moduleAmoduleBmoduleC
ログイン後にコピー
ログイン後にコピー
ログイン後にコピー

  • flush layout 的内容 包含浏览器渲染的函数
  • 然后进入核心的取数据、模板拼装,将可执行的内容 flush 到浏览器
  • 浏览器进行渲染(此处还未引入并行处理)
  • 关闭 body 和 HTML 标签
  • 结束响应 完成一个请求

express 实现

var express = require('express');var app = express();var fs = require('fs');app.get('/', function (req, res) {  // flush layout and assets  var layoutHtml = fs.readFileSync(__dirname + "/layout.html").toString();  res.write(layoutHtml);    // fetch data and render  res.write('<script>renderFlushCon("#A","moduleA");</script>');  res.write('<script>renderFlushCon("#C","moduleC");</script>');  res.write('<script>renderFlushCon("#B","moduleB");</script>');    // close body and html tags  res.write('</body></html>');  // finish the response  res.end();});app.listen(3000);
ログイン後にコピー

页面输出:

moduleAmoduleBmoduleC
ログイン後にコピー
ログイン後にコピー
ログイン後にコピー

  • express 建立在 Node.js 内置的 HTTP 模块上,实现的方式差不多

koa 实现

var koa = require('koa');var app = koa();app.use(function *() {    this.body = 'Hello world';});app.listen(3000);
ログイン後にコピー

  • Koa 不支持 直接调用底层 res 进行响应处理。 res.write()/res.end() 就是个雷区,有幸踩过。
  • koa 中,this 这个上下文对 Node.js 的 request 和 response 对象的封装。this.body 是 response 对象的一个属性。
  • 感觉 koa 的世界就剩下了 generator 和 this.body ,怎么办?继续看文档~
  • this.body 可以设置为字符串, buffer 、stream 、 对象 、 或者 null 也行。
  • stream stream stream 说三遍可以变得很重要。

流的意义

关于流,推荐看 @愈之的 通通连起来 – 无处不在的流 ,感触良多,对流有了新的认识,于是接下来连连看。

var koa = require('koa');var View = require('./view');var app = module.exports = koa();app.use(function* () {  this.type = 'html';  this.body = new View(this);});app.listen(3000);
ログイン後にコピー

view.js

var Readable = require('stream').Readable;var util = require('util');var co = require('co');var fs = require('fs');module.exports = Viewutil.inherits(View, Readable);function View(context) {  Readable.call(this, {});  // render the view on a different loop  co.call(this, this.render).catch(context.onerror);}View.prototype._read = function () {};View.prototype.render = function* () {  // flush layout and assets  var layoutHtml = fs.readFileSync(__dirname + "/layout.html").toString();  this.push(layoutHtml);    // fetch data and render  this.push('<script>renderFlushCon("#A","moduleA");</script>');  this.push('<script>renderFlushCon("#C","moduleC");</script>');  this.push('<script>renderFlushCon("#B","moduleB");</script>');    // close body and html tags  this.push('</body></html>');  // end the stream  this.push(null);};
ログイン後にコピー

页面输出:

moduleAmoduleBmoduleC
ログイン後にコピー
ログイン後にコピー
ログイン後にコピー

  • Transfer-Encoding:chunked
  • 服务端和浏览器端建立管道,通过 this.push 将内容从服务端传输到浏览器端

并行的实现

目前我们已经完成了 koa 和 express 分块传输的实现,我们知道要输出的模块 A 、模块 B 、模块 C 需要并行在服务端生成内容。在这个时候来回顾下传统的网页渲染方式,A / B / C 模块同步渲染:

采用分块传输的模式,A / B / C 服务端顺序执行,A / B / C 分块传输到浏览器渲染:

时间明显少了,然后把服务端的顺序执行换成并行执行的话:

通过此图,并行的意义是显而易见的。为了寻找并行执行的方案,就不得不 追溯异步编程 的历史。(读史可以明智,可以知道当下有多不容易)

callback 的方式

  • 首先 过多 callback 嵌套 实现异步编程是地狱
  • 第二 选择绕过地狱,选择成熟的模块来取代

async 的方式

  • async 算是异步编码流程控制中的元老。
  • parallel(tasks, [callback]) 并行执行多个函数,每个函数都是立即执行,不需要等待其它函数先执行。传给最终 callback 的数组中的数据按照 tasks 中声明的顺序,而不是执行完成的顺序。

var Readable = require('stream').Readable;var inherits = require('util').inherits;var co = require('co');var fs = require('fs');var async = require('async');inherits(View, Readable);function View(context) {  Readable.call(this, {});  // render the view on a different loop  co.call(this, this.render).catch(context.onerror);}View.prototype._read = function () {};View.prototype.render = function* () {  // flush layout and assets  var layoutHtml = fs.readFileSync(__dirname + "/layout.html").toString();  this.push(layoutHtml);  var context = this;  async.parallel([    function(cb) {      setTimeout(function(){        context.push('<script>renderFlushCon("#A","moduleA");</script>');        cb();      }, 1000);    },    function(cb) {      context.push('<script>renderFlushCon("#C","moduleC");</script>');      cb();    },    function(cb) {      setTimeout(function(){        context.push('<script>renderFlushCon("#B","moduleB");</script>');        cb();      }, 2000);    }  ], function (err, results) {    // close body and html tags    context.push('</body></html>');    // end the stream    context.push(null);  });  };module.exports = View;
ログイン後にコピー

页面输出:

moduleCmoduleAmoduleB
ログイン後にコピー

  • 模块显示的顺序是 C>A>B ,这个结果也说明了 Node.js IO 不阻塞
  • 优先 flush layout 的内容
  • 利用 async.parallel 并行处理 A 、B 、C ,通过 cb() 回调来表示该任务执行完成
  • 任务执行完成后 执行结束回调,此时关闭 body/html 标签 并结束 stream

每个 task 函数执行中,如果有出错,会直接最后的 callback。此时会中断,其他未执行完的任务也会停止,所以这个并行执行的方法处理异常的情况需要比较谨慎。

另外 async 里面有个 each 的方法也可以实现异步编程的并行执行:

each(arr, iterator(item, callback), callback(err))
ログイン後にコピー

稍微改造下:

var options = [  {id:"A",html:"moduleA",delay:1000},  {id:"B",html:"moduleB",delay:0},  {id:"C",html:"moduleC",delay:2000}];async.forEach(options, function(item, callback) {   setTimeout(function(){    context.push('<script>renderFlushCon("#'+item.id+'","'+item.html+'");</script>');    callback();  }, item.delay);  }, function(err) {   // close body and html tags  context.push('</body></html>');  // end the stream  context.push(null);});
ログイン後にコピー

  • 结果和 parallel 的方式是一致的,不同的是这种方式关注执行过程,而 parallel 更多的时候关注任务数据

我们会发现在使用 async 的时候,已经引入了 co ,co 也是异步编程的利器,看能否找到更简便的方法。

co

co 作为一个异步流程简化工具,能否利用强大的生成器特性实现我们的并行执行的目标。其实我们要的场景很简单:

多个任务函数并行执行,完成最后一个任务的时候可以进行通知执行后面的任务。

var Readable = require('stream').Readable;var inherits = require('util').inherits;var co = require('co');var fs = require('fs');// var async = require('async');inherits(View, Readable);function View(context) {  Readable.call(this, {});  // render the view on a different loop  co.call(this, this.render).catch(context.onerror);}View.prototype._read = function () {};View.prototype.render = function* () {  // flush layout and assets  var layoutHtml = fs.readFileSync(__dirname + "/layout.html").toString();  this.push(layoutHtml);  var context = this;  var options = [    {id:"A",html:"moduleA",delay:100},    {id:"B",html:"moduleB",delay:0},    {id:"C",html:"moduleC",delay:2000}  ];  var taskNum = options.length;  var exec = options.map(function(item){opt(item,function(){    taskNum --;    if(taskNum === 0) {      done();    }   })});  function opt(item,callback) {    setTimeout(function(){      context.push('<script>renderFlushCon("#'+item.id+'","'+item.html+'");</script>');      callback();    }, item.delay);  }  function done() {    context.push('</body></html>');      // end the stream    context.push(null);  }  co(function* () {     yield exec;  });  };module.exports = View;
ログイン後にコピー

  • yield array 并行执行数组内的任务。
  • 为了不使用 promise 在数量可预知的情况 ,加了个计数器来判断是否已经结束,纯 co 实现还有更好的方式?
  • 到这个时候,才发现生成器的特性并不能应运自如,需要补一补。

co 结合 promise

这个方法由@大果同学赞助提供,写起来优雅很多。

var options = [  {id:"A",html:"moduleAA",delay:100},  {id:"B",html:"moduleBB",delay:0},  {id:"C",html:"moduleCC",delay:2000}];var exec = options.map(function(item){ return opt(item); });function opt(item) {  return new Promise(function (resolve, reject) {  setTimeout(function(){      context.push('<script>renderFlushCon("#'+item.id+'","'+item.html+'");</script>');      resolve(item);    }, item.delay);  });}function done() {  context.push('</body></html>');    // end the stream  context.push(null);}co(function* () {   yield exec;}).then(function(){  done();});
ログイン後にコピー

ES 7 async/wait

如果成为标准并开始引入,相信代码会更精简、可读性会更高,而且实现的思路会更清晰。

async function flush(Something) {  	await Promise.all[moduleA.flush(), moduleB.flush(),moduleC.flush()]	context.push('</body></html>');      // end the stream    context.push(null);}
ログイン後にコピー

  • 此段代码未曾跑过验证,思路和代码摆在这里,ES 7 跑起来 ^_^。

Midway

写到这里太阳已经下山了,如果在这里来个“预知后事如何,请听下回分解”,那么前面的内容就变成一本没有主角的小说。

Midway 是好东西,是前后端分离的产物。分离不代表不往来,而是更紧密和流畅。因为职责清晰,前后端有时候可以达到“你懂的,懂!”,然后一个需求就可以明确了。用 Node.js 代替 Webx MVC 中的 View 层,给前端实施 Bigpipe 带来无限的方便。

>Midway 封装了 koa 的功能,屏蔽了一些复杂的元素,只暴露出最简单的 MVC 部分给前端使用,降低了很大一部分配置的成本。

一些信息

  • Midway 其实支持 express 框架和 koa 框架,目前主流应该都是 koa,Midway 5.1 之后应该不会兼容双框架。
  • Midway 可以更好地支持 generators 特性
  • midway-render this.render(xtpl,data) 内容直接通过 this.body 输出到页面。

function renderView(basePath, viewName, data) {  var me = this;  var filepath = path.join(basePath, viewName);  data = utils.assign({}, me.state, data);  return new Promise(function(resolve, reject) {    function callback(err, ret) {      if (err) {        return reject(err);      }      // 拼装后直接赋值this.body      me.body = ret;      resolve(ret);    }    render(filepath, data, callback);  });}
ログイン後にコピー

MVC

  • Midway 的专注点是做前后端分离,Model 层其实是对后端的 Model 做一层代理,数据依赖后端提供。
  • View 层 模板使用 xtpl 模板,前后端的模板统一。
  • Controller 把路由和视图完整的结合在了一起,通常在 Controller 中实现 this.render。

Bigpipe 的位置

了解 Midway 这些信息,其实是为了弄清楚 Bigpipe 在 Midway 里面应该在哪里接入会比较合适:

  • Bigpipe 方案需要实现对内容的分块传输,所以也是在 Controller 中使用。
  • 拼装模板需要 midway-xtpl 实现拼装好字符串,然后通过 Bigpipe 分块输出。
  • Bigpipe 可以实现对各个模块进行取数据和拼装模块内容的功能。

建议在 Controller 中作为 Bigpipe 模块引入使用,取代原有 this.render 的方式进行内容分块输出

场景

什么样的场景比较适合 Bigpipe,结合我们现有的东西和开发模式。

  • 类似于卖家中心,模块多,页面长,首屏又是用户核心内容。
  • 每个模块的功能相对独立,模板和数据都相对独立。
  • 非首屏模块还是建议用滚动加载,减少首屏传输量。
  • 主框架输出 assets 和 bigpipe 需要的脚本,主要的是需要为模块预先占位。
  • 首屏模块是可以固定或者通过计算确认。
  • 模块除了分块输出,最好也支持异步加载渲染的方式。

封装

最后卖家中心的使用和 Bigpipe 的封装,我们围绕着前面核心实现的分块传输和并行执行,目前的封装是这样的:

由于 Midway this.render 除了拼装模板会直接 将内容赋值到 this.body,这种时候回直接中断请求,无法实现我们分块传输的目标。所以做了一个小扩展:

midway-render 引擎里面 添加只拼装模板不输出的方法 this.Html

// just output html no render; app.context.Html = utils.partial(engine.renderViewText, config.path);
ログイン後にコピー

renderViewText

function renderViewText(basePath, viewName, data) {  var me = this;  var filepath = path.join(basePath, viewName);  data = utils.assign({}, me.state, data);  return new Promise(function(resolve, reject) {    render(filepath, data, function(err, ret){      if (err) {        return reject(err);      }      //此次 去掉了 me.body=ret      resolve(ret);    });  });}
ログイン後にコピー

  • midway-render/midway-xtpl 应该有扩展,但是没找到怎么使用,所以选择这样的方式。

View.js 模块

'use strict';var util = require('util');var async = require('async');var Readable = require('stream').Readable;var midway = require('midway');var DataProxy = midway.getPlugin('dataproxy');// 默认主体框架var defaultLayout = '<!DOCTYPE html><html><head></head><body></body>';exports.createView = function() {  function noop() {};  util.inherits(View, Readable);  function View(ctx, options) {    Readable.call(this);    ctx.type = 'text/html; charset=utf-8';    ctx.body = this;    ctx.options = options;    this.context = ctx;    this.layout = options.layout || defaultLayout;    this.pagelets = options.pagelets || [];    this.mod = options.mod || 'bigpipe';    this.endCB = options.endCB || noop;  }  /** * * @type {noop} * @private */  View.prototype._read = noop;  /** * flush 内容 */  View.prototype.flush = function* () {    // flush layout    yield this.flushLayout();    // flush pagelets    yield this.flushPagelets();  };  /** * flush主框架内容 */  View.prototype.flushLayout = function* () {    this.push(this.layout);  }  /** * flushpagelets的内容 */  View.prototype.flushPagelets = function* () {    var self = this;    var pagelets = this.pagelets;    // 并行执行    async.each(pagelets, function(pagelet, callback) {      self.flushSinglePagelet(pagelet, callback);    }, function(err) {      self.flushEnd();    });  }  /** * flush 单个pagelet * @param pagelet * @param callback */  View.prototype.flushSinglePagelet = function(pagelet, callback) {    var self = this,      context = this.context;    this.getDataByDataProxy(pagelet,function(data){      var data = pagelet.formateData(data, pagelet) || data;      context.Html(pagelet.tpl, data).then(function(html) {        var selector = '#' + pagelet.id;        var js = pagelet.js;        self.arrive(selector,html,js);        callback();      });    });  }  /** * 获取后端数据 * @param pagelet * @param callback */  View.prototype.getDataByDataProxy = function(pagelet, callback) {    var context = this.context;    if (pagelet.proxy) {      var proxy = DataProxy.create({        getData: pagelet.proxy      });      proxy.getData()        .withHeaders(context.request.headers)        .done(function(data) {          callback && callback(data);        })        .fail(function(err) {          console.error(err);        });    }else {      callback&&callback({});    }  }  /** * 关闭html结束stream */  View.prototype.flushEnd = function() {    this.push('</html>');    this.push(null);  }  // Replace the contents of `selector` with `html`.  // Optionally execute the `js`.  View.prototype.arrive = function (selector, html, js) {      this.push(wrapScript(          'BigPipe(' +              JSON.stringify(selector) + ', ' +              JSON.stringify(html) +              (js ? ', ' + JSON.stringify(js) : '') + ')'      ))  }  function wrapScript(js) {    var id = 'id_' + Math.random().toString(36).slice(2)    return '<script id="' + id + '">'      + js      + ';remove(\'#' + id + '\');</script>'  }  return View;}
ログイン後にコピー

  • context.html 拼装各个 pagelet 的内容

Controller 调用

var me = this;var layoutHtml = yield this.Html('p/seller_admin_b/index', data);yield new View(me, {  layout: layoutHtml, // 拼装好layout模板  pagelets: pageletsConfig,  mod: 'bigpie'  // 预留模式选择}).flush();
ログイン後にコピー

  • layoutHtml 拼装好主框架模板
  • 每个 pagelets 的配置

{	id: 'seller_info',//该pagelet的唯一id    proxy: 'Seller.Module.Data.seller_info', // 接口配置    tpl: 'sellerInfo.xtpl', //需要的模板    js: '' //需要执行的js}
ログイン後にコピー

  • データの取得とテンプレートの組み立てには、proxy と tpl を並行して実行する必要があります
  • js は通常、モジュールを初期化します
改善点

アイデアとコードの実装は、既存のシナリオと技術的背景に基づいています。統合されたソリューションはまだ形成されておらず、それをサポートするにはさらに多くのシナリオが必要です。現在、改善できる点がいくつかあります:

    ES6/ES7 の新機能を使用してコードをよりエレガントに変換でき、Midway アップグレードと併せて改善を行うことができます。
  • チャンク送信メカニズムは、一部の下位バージョンのブラウザと互換性がありません。モジュールの非同期ロードを実装し、二重ルートに分割し、ユーザーの機器に応じてルートを切り替えることが最善です。
  • 各モジュールとコンテンツの例外処理、リクエストの時間制限を設定し、制限時間に達したらリンクを閉じ、ページがハングしないようにします。このとき、本来ブロックで送信する必要があるモジュールは非同期で導入されます。
  • 現在、並列実装ソリューションでは async.each が使用されており、パフォーマンスの観点からさまざまなソリューションを比較する必要があります
ソース:php.cn
このウェブサイトの声明
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
人気のチュートリアル
詳細>
最新のダウンロード
詳細>
ウェブエフェクト
公式サイト
サイト素材
フロントエンドテンプレート