首頁 > web前端 > js教程 > 圖文結合帶你搞懂Nodejs中的事件循環

圖文結合帶你搞懂Nodejs中的事件循環

青灯夜游
發布: 2021-10-09 18:57:52
轉載
2310 人瀏覽過

本篇文章透過圖文結合的形式來帶大家搞懂Nodejs中的事件循環,希望對大家有幫助!

圖文結合帶你搞懂Nodejs中的事件循環

以下全文7000字,請在你思路清晰、精力充沛的時刻觀看。保證你理解後很久忘不掉。 【推薦學習:《nodejs 教學》】

圖文結合帶你搞懂Nodejs中的事件循環

Node事件循環


#Node底層使用的語言libuv,是一個c 語言。他用來操作底層的作業系統,封裝了作業系統的介面。 Node的事件循環也是用libuv來寫的,所以Node生命週期和瀏覽器的還是有差別的。

因為Node和作業系統打交道,所以事件循環比較複雜,也有一些自己特有的API。

事件循環在不同的作業系統裡有一些細微的差異。這將涉及到操作系統的知識,暫時不表。 本次只介紹JS主線程中,Node的運作流程。 Node的其他線程暫時也不擴展。

圖文結合帶你搞懂Nodejs中的事件循環

事件循環圖

圖文結合帶你搞懂Nodejs中的事件循環

#說好的一張圖,也不賣關子。下邊這張圖搞清楚了,事件循環就學會了。

事件循環圖圖文結合帶你搞懂Nodejs中的事件循環

#事件循環圖-結構

為了讓大家先有個大局觀,先貼一張目錄結構圖在前邊:

圖文結合帶你搞懂Nodejs中的事件循環目錄

#接下來詳細展開說說

  • 主執行緒
  • #主執行緒
    • 上圖中,幾個色塊的意義:
    • main
      :啟動入口文件,執行主函數
  • #event loop
  • :檢查是否要進入事件循環檢查其他執行緒是否還有待處理事項
檢查其他任務是否還在進行中(例如計時器、檔案讀取操作等任務是否完成)

有以上情況,進入事件循環,運行其他任務事件循環的過程:沿著從timers到close callbacks這個流程,走一圈。到event loop看是否結束,沒結束再走一圈。

圖文結合帶你搞懂Nodejs中的事件循環over

:所有的事情都完畢,結束

##事件循環圈

事件循環圈圖中灰色的圈跟作業系統有關係,不是本章解析重點。重點關注黃色、橙色的圓圈還有中間橘黃的方框。

我們把每一圈的事件循環叫做「一次循環」、又叫「一次輪詢」、又叫「一次Tick」。
  • 一次迴圈要經過六個階段:
  • timers:計時器(setTimeout、setInterval等的回呼函數存放在裡邊)
  • pending callback

  • poll:輪詢佇列(除timers、check之外的回呼存放在這裡)

check:檢查階段(使用setImmediate 的回呼會直接進入這個佇列)圖文結合帶你搞懂Nodejs中的事件循環

####close callbacks######################################################################################### #本次我們只專注於上邊標紅的三個重點。 ###

工作原理

  • #每一個階段都會維護一個事件佇列。可以把每一個圈想像成一個事件隊列。
  • 這就跟瀏覽器不一樣了,瀏覽器最多兩個佇列(巨集佇列、微佇列)。但是在node裡邊有六個佇列
  • 到達一個佇列後,檢查佇列內是否有任務(也就是看是否有回呼函數)需要執行。如果有,就依序執行,直到全部執行完畢、清空佇列。
  • 如果沒有任務,進入下一個佇列去檢查。直到所有隊列檢查一遍,算一個輪詢。
  • 其中,timerspending callbackidle prepare等執行完畢後,到達poll佇列。

timers佇列的工作原理

#timers並非真正意義上的佇列,他內部存放的是計時器。
每次到達這個佇列,會檢查計時器執行緒內的所有計時器,計時器執行緒內部多個計時器會依照時間順序排序。

檢查過程:將每個計時器按順序分別計算一遍,計算計時器開始計時的時間到當前時間是否滿足計時器的間隔參數設定(例如1000ms,計算計時器開始計時到現在是否有1m)。當某個計時器檢查通過,則執行其回呼函數。

poll佇列的運作方式

  • #如果poll中有回呼函數需要執行,依序執行回調,直到清空隊列。
  • 如果poll中沒有回呼函數需要執行,已經是空隊列了。則會在這裡等待,等待其他隊列中出現回調,
    • 如果其他隊列中出現回調,則從poll向下到over,結束該階段,進入下一階段。
    • 如果其他佇列也都沒有回調,則持續在poll佇列等待,直到任何一個佇列出現回呼後再進行工作。 (是小懶蟲的處事方式)

#範例梳理事件流程

setTimeout(() => {
  console.log('object');
}, 5000)
console.log('node');
登入後複製

以上程式碼的事件流程梳理

  • #進入主線程,執行setTimeout(),回呼函數作為非同步任務被放入非同步佇列timers佇列中,暫時不執行。
  • 繼續向下,執行計時器後邊的console,列印「node」。
  • 判斷是否有事件循環。是,走一圈輪詢:從timers - pending callback - idle prepare…
  • poll隊列停下來並等待。
    • 由於此時沒到5秒,timers隊列無任務,所以一直在poll隊列卡著,同時輪詢檢查其他隊列是否有任務。
  • 等5秒到達,setTimeout的回呼塞到timers內,例行輪詢檢查到timers隊列有任務,則向下走,經過check、close callbacks後到達timers。將timers隊列清空。
  • 繼續輪詢到poll等待,詢問是否還需要event loop,不需要,則到達over結束。

要理解這個問題,看下邊的程式碼及流程解析:

setTimeout(function t1() {
  console.log('setTimeout');
}, 5000)
console.log('node 生命周期');

const http = require('http')

const server = http.createServer(function h1() {
  console.log('请求回调');
});

server.listen(8080)
登入後複製

程式碼分析如下:

  • 照舊,先執行主線程,列印「node 生命週期」、引入http後建立http服務。
  • 然後event loop檢查是否有非同步任務,檢查發現有計時器任務和請求任務。所以進入事件循環。
  • 六個佇列都沒任務,則在poll佇列等待。如下圖:

    圖文結合帶你搞懂Nodejs中的事件循環

  • 過了五秒,timers中有了任務,則流程從poll放行向下,經過check和close callbacks佇列後,到達event loop。
  • event loop檢查是否有非同步任務,檢查發現有計時器任務和請求任務。所以再次進入事件循環。
  • 到達timers佇列,發現有回調函數任務,則依序執行回調,清空timers佇列(當然這裡只有一個5秒到達後的回調,所以直接執行完了即可),列印出“ setTimeout」。如下圖

    圖文結合帶你搞懂Nodejs中的事件循環

  • 清空timers隊列後,輪詢繼續向下到達poll隊列,由於poll隊列現在是空隊列,所以在這裡等待。
  • 後來,假設使用者請求發來了,h1回呼函數被放到poll佇列。於是poll中有回呼函數需要執行,依序執行回調,直到清空poll佇列。
  • poll隊列已清空,此時poll隊列是空隊列,繼續等待。

    圖文結合帶你搞懂Nodejs中的事件循環

  • 由于node线程一直holding在poll队列,等很长一段时间还是没有任务来临时,会自动断开等待(不自信表现),向下执行轮询流程,经过check、close callbacks后到达event loop
  • 到了event loop后,检查是否有异步任务,检查发现有请求任务。(此时定时器任务已经执行完毕,所以没有了),则继续再次进入事件循环。
  • 到达poll队列,再次holding……
  • 再等很长时间没有任务来临,自动断开到even loop(再补充一点无任务的循环情况)
  • 再次回到poll队列挂起
  • 无限循环……

梳理事件循环流程图:

注意:下图中的“是否有任务”的说法表示“是否有本队列的任务”。

1圖文結合帶你搞懂Nodejs中的事件循環

event loop流程梳理

再用一个典型的例子验证下流程:

const startTime = new Date();

setTimeout(function f1() {
  console.log('setTimeout', new Date(), new Date() - startTime);
}, 200)

console.log('node 生命周期', startTime);

const fs = require('fs')

fs.readFile('./poll.js', 'utf-8', function fsFunc(err, data) {
  const fsTime = new Date()
  console.log('fs', fsTime);
  while (new Date() - fsTime < 300) {
  }
  console.log(&#39;结束死循环&#39;, new Date());
});
登入後複製

连续运行三遍,打印结果如下:

1圖文結合帶你搞懂Nodejs中的事件循環

执行流程解析:

  • 执行全局上下文,打印「node 生命周期 + 时间」

  • 询问是否有event loop

  • 有,进入timers队列,检查没有计时器(cpu处理速度可以,这时还没到200ms)

  • 轮询进入到poll,读文件还没读完(比如此时才用了20ms),因此poll队列是空的,也没有任务回调

  • 在poll队列等待……不断轮询看有没有回调

  • 文件读完,poll队列有了fsFunc回调函数,并且被执行,输出「fs + 时间」

  • 在while死循环那里卡300毫秒,

  • 死循环卡到200ms的时候,f1回调进入timers队列。但此时poll队列很忙,占用了线程,不会向下执行。

  • 直到300ms后poll队列清空,输出「结束死循环 + 时间」

  • event loop赶紧向下走

  • 再来一轮到timers,执行timers队列里的f1回调。于是看到「setTimeout + 时间」

  • timers队列清空,回到poll队列,没有任务,等待一会。

  • 等待时间够长后,向下回到event loop。

  • event loop检查没有其他异步任务了,结束线程,整个程序over退出。

check 阶段

检查阶段(使用 setImmediate 的回调会直接进入这个队列)

check队列的实际工作原理

真正的队列,里边扔的就是待执行的回调函数的集合。类似[fn,fn]这种形式的。
每次到达check这个队列后,立即按顺序执行回调函数即可【类似于[fn1,fn2].forEach((fn)=>fn())的感觉】

所以说,setImmediate不是一个计时器的概念。

如果你去面试,涉及到Node环节,可能会遇到下边这个问题:setImmediate和setTimeout(0)谁更快。

setImmediate() 与 setTimeout(0) 的对比

  • setImmediate的回调是异步的,和setTimeout回调性质一致。
  • setImmediate回调在check队列,setTimeout回调在timers队列(概念意义,实际在计时器线程,只是setTimeout在timers队列做检查调用而已。详细看timers的工作原理)。
  • setImmediate函数调用后,回调函数会立即push到check队列,并在下次eventloop时被执行。setTimeout函数调用后,计时器线程增加一个定时器任务,下次eventloop时会在timers阶段里检查判断定时器任务是否到达时间,到了则执行回调函数。
  • 综上,setImmediate的运算速度比setTimeout(0)的要快,因为setTimeout还需要开计时器线程,并增加计算的开销。

二者的效果差不多。但是执行顺序不定

观察以下代码:

setTimeout(() => {
  console.log(&#39;setTimeout&#39;);
}, 0);

setImmediate(() => {
  console.log(&#39;setImmediate&#39;);
});
登入後複製

多次反复运行,执行效果如下:

1圖文結合帶你搞懂Nodejs中的事件循環

顺序不定

可以看到多次运行,两句console.log打印的顺序不定。
这是因为setTimeout的间隔数最小填1,虽然下边代码填了0。但实际计算机执行当1ms算。(这里注意和浏览器的计时器区分。在浏览器中,setInterval的最小间隔数为10ms,小于10ms则会被设置为10;设备供电状态下,间隔最小为16.6ms。)

以上代码,主线程运行的时候,setTimeout函数调用,计时器线程增加一个定时器任务。setImmediate函数调用后,其回调函数立即push到check队列。主线程执行完毕。

eventloop判断时,发现timers和check队列有内容,进入异步轮询:

第一种情况:等到了timers里这段时间,可能还没有1ms的时间,定时器任务间隔时间的条件不成立所以timers里还没有回调函数。继续向下到了check队列里,这时候setImmediate的回调函数早已等候多时,直接执行。而再下次eventloop到达timers队列,定时器也早已成熟,才会执行setTimeout的回调任务。于是顺序就是「setImmediate -> setTimeout」。

第二种情况:但也有可能到了timers阶段时,超过了1ms。于是计算定时器条件成立,setTimeout的回调函数被直接执行。eventloop再向下到达check队列执行setImmediate的回调。最终顺序就是「setTimeout -> setImmediate」了。

所以,只比较这两个函数的情况下,二者的执行顺序最终结果取决于当下计算机的运行环境以及运行速度。

二者时间差距的对比代码

------------------setTimeout测试:-------------------
let i = 0;
console.time(&#39;setTimeout&#39;);
function test() {
  if (i < 1000) {
    setTimeout(test, 0)
    i++
  } else {
    console.timeEnd(&#39;setTimeout&#39;);
  }
}
test();

------------------setImmediate测试:-------------------
let i = 0;
console.time(&#39;setImmediate&#39;);
function test() {
  if (i < 1000) {
    setImmediate(test)
    i++
  } else {
    console.timeEnd(&#39;setImmediate&#39;);
  }
}
test();
登入後複製

运行观察时间差距:

1圖文結合帶你搞懂Nodejs中的事件循環

setTimeout与setImmediate时间差距

可见setTimeout远比setImmediate耗时多得多
这是因为setTimeout不仅有主代码执行的时间消耗。还有在timers队列里,对于计时器线程中各个定时任务的计算时间。

结合poll队列的面试题(考察timers、poll和check的执行顺序)

如果你看懂了上边的事件循环图,下边这道题难不倒你!

// 说说下边代码的执行顺序,先打印哪个?
const fs = require(&#39;fs&#39;)
fs.readFile(&#39;./poll.js&#39;, () => {
  setTimeout(() => console.log(&#39;setTimeout&#39;), 0)
  setImmediate(() => console.log(&#39;setImmediate&#39;))
})
登入後複製

上边这种代码逻辑,不管执行多少次,肯定都是先执行setImmediate。

1圖文結合帶你搞懂Nodejs中的事件循環

先执行setImmediate

因为fs各个函数的回调是放在poll队列的。当程序holding在poll队列后,出现回调立即执行。
回调内执行setTimeout和setImmediate的函数后,check队列立即增加了回调。
回调执行完毕,轮询检查其他队列有内容,程序结束poll队列的holding向下执行。
check是poll阶段的紧接着的下一个。所以在向下的过程中,先执行check阶段内的回调,也就是先打印setImmediate。
到下一轮循环,到达timers队列,检查setTimeout计时器符合条件,则定时器回调被执行。

nextTick 与 Promise

说完宏任务,接下来说下微任务

  • 二者都是「微队列」,执行异步微任务。
  • 二者不是事件循环的一部分,程序也不会开启额外的线程去处理相关任务。(理解:promise里发网络请求,那是网络请求开的网络线程,跟Promise这个微任务没关系)
  • 微队列设立的目的就是让一些任务「马上」、「立即」优先执行。
  • nextTick与Promise比较,nextTick的级别更高。

nextTick表现形式

process.nextTick(() => {})
登入後複製

Promise表现形式

Promise.resolve().then(() => {})
登入後複製

如何参与事件循环?

事件循环中,每执行一个回调前,先按序清空一次nextTick和promise。

// 先思考下列代码的执行顺序
setImmediate(() => {
  console.log(&#39;setImmediate&#39;);
});

process.nextTick(() => {
  console.log(&#39;nextTick 1&#39;);
  process.nextTick(() => {
    console.log(&#39;nextTick 2&#39;);
  })
})

console.log(&#39;global&#39;);


Promise.resolve().then(() => {
  console.log(&#39;promise 1&#39;);
  process.nextTick(() => {
    console.log(&#39;nextTick in promise&#39;);
  })
})
登入後複製

最终顺序:

  • global

  • nextTick 1

  • nextTick 2

  • promise 1

  • nextTick in promise

  • setImmediate

两个问题:

基于上边的说法,有两个问题待思考和解决:

  • 每走一个异步宏任务队列就查一遍nextTick和promise?还是每执行完 宏任务队列里的一个回调函数就查一遍呢?

  • 如果在poll的holding阶段,插入一个nextTick或者Promise的回调,会立即停止poll队列的holding去执行回调吗?

圖文結合帶你搞懂Nodejs中的事件循環

上边两个问题,看下边代码的说法

setTimeout(() => {
  console.log(&#39;setTimeout 100&#39;);
  setTimeout(() => {
    console.log(&#39;setTimeout 100 - 0&#39;);
    process.nextTick(() => {
      console.log(&#39;nextTick in setTimeout 100 - 0&#39;);
    })
  }, 0)
  setImmediate(() => {
    console.log(&#39;setImmediate in setTimeout 100&#39;);
    process.nextTick(() => {
      console.log(&#39;nextTick in setImmediate in setTimeout 100&#39;);
    })
  });
  process.nextTick(() => {
    console.log(&#39;nextTick in setTimeout100&#39;);
  })
  Promise.resolve().then(() => {
    console.log(&#39;promise in setTimeout100&#39;);
  })
}, 100)

const fs = require(&#39;fs&#39;)
fs.readFile(&#39;./1.poll.js&#39;, () => {
  console.log(&#39;poll 1&#39;);
  process.nextTick(() => {
    console.log(&#39;nextTick in poll ======&#39;);
  })
})

setTimeout(() => {
  console.log(&#39;setTimeout 0&#39;);
  process.nextTick(() => {
    console.log(&#39;nextTick in setTimeout&#39;);
  })
}, 0)

setTimeout(() => {
  console.log(&#39;setTimeout 1&#39;);
  Promise.resolve().then(() => {
    console.log(&#39;promise in setTimeout1&#39;);
  })
  process.nextTick(() => {
    console.log(&#39;nextTick in setTimeout1&#39;);
  })
}, 1)

setImmediate(() => {
  console.log(&#39;setImmediate&#39;);
  process.nextTick(() => {
    console.log(&#39;nextTick in setImmediate&#39;);
  })
});

process.nextTick(() => {
  console.log(&#39;nextTick 1&#39;);
  process.nextTick(() => {
    console.log(&#39;nextTick 2&#39;);
  })
})

console.log(&#39;global ------&#39;);

Promise.resolve().then(() => {
  console.log(&#39;promise 1&#39;);
  process.nextTick(() => {
    console.log(&#39;nextTick in promise&#39;);
  })
})

/** 执行顺序如下
global ------
nextTick 1
nextTick 2
promise 1
nextTick in promise
setTimeout 0 // 解释问题1. 没有上边的nextTick和promise,setTimeout和setImmediate的顺序不一定,有了以后肯定是0先开始。
// 可见,执行一个队列之前,就先检查并执行了nextTick和promise微队列
nextTick in setTimeout
setTimeout 1
nextTick in setTimeout1
promise in setTimeout1
setImmediate
nextTick in setImmediate
poll 1
nextTick in poll ======
setTimeout 100
nextTick in setTimeout100
promise in setTimeout100
setImmediate in setTimeout 100
nextTick in setImmediate in setTimeout 100
setTimeout 100 - 0
nextTick in setTimeout 100 - 0
 */
登入後複製

以上代码执行多次,顺序不变,setTimeout和setImmediate的顺序都没变。

执行顺序及具体原因说明如下:

  • global :主线程同步任务,率先执行没毛病

  • nextTick 1:执行异步宏任务之前,清空异步微任务,nextTick优先级高,先行一步

  • nextTick 2:执行完上边这句代码,又一个nextTick微任务,立即率先执行

  • promise 1:执行异步宏任务之前,清空异步微任务,Promise的优先级低,所以在nextTick完了以后立即执行

  • nextTick in promise:清空Promise队列的过程中,遇到nextTick微任务,立即执行、清空

  • setTimeout 0: 解释第一个问题. 没有上边的nextTick和promise,只有setTimeout和setImmediate时他俩的执行顺序不一定。有了以后肯定是0先开始。可见,执行一个宏队列之前,就先按顺序检查并执行了nextTick和promise微队列。等微队列全部执行完毕,setTimeout(0)的时机也成熟了,就被执行。

  • nextTick in setTimeout:执行完上边这句代码,又一个nextTick微任务,立即率先执行 【这种回调函数里的微任务,我不能确定是紧随同步任务执行的;还是放到微任务队列,等下一个宏任务执行前再清空的他们。但是顺序看上去和立即执行他们一样。不过我比较倾向于是后者:先放到微任务队列等待,下一个宏任务执行前清空他们。】

  • setTimeout 1:因为执行微任务耗费时间,导致此时timers里判断两个0和1的setTimeout计时器已经结束,所以两个setTimeout回调都已加入队列并被执行

  • nextTick in setTimeout1:执行完上边这句代码,又一个nextTick微任务,立即率先执行 【可能是下一个宏任务前清空微任务】

  • promise in setTimeout1:执行完上边这句代码,又一个Promise微任务,立即紧随执行 【可能是下一个宏任务前清空微任务】

  • setImmediate:poll队列回调时机未到,先行向下到check队列,清空队列,立即执行setImmediate回调

  • nextTick in setImmediate:执行完上边这句代码,又一个nextTick微任务,立即率先执行 【可能是下一个宏任务前清空微任务】

  • poll 1:poll队列实际成熟,回调触发,同步任务执行。

  • nextTick in poll :执行完上边这句代码,又一个nextTick微任务,立即率先执行 【可能是下一个宏任务前清空微任务】

  • setTimeout 100:定时器任务到达时间,执行回调。并在回调里往微任务推入了nextTick、Promise,往宏任务的check里推入了setImmediate的回调。并且也开启了计时器线程,往timers里增加了下一轮回调的可能。

  • nextTick in setTimeout100:宏任务向下前,率先执行定时器回调内新增的微任务-nextTick 【这里就能确定了,是下一个宏任务前清空微任务的流程】

  • promise in setTimeout100:紧接着执行定时器回调内新增的微任务-Promise 【清空完nextTick清空Promise的顺序】

  • setImmediate in setTimeout 100:这次setImmediate比setTimeout(0)先执行的原因是:流程从timers向后走到check队列,已经有了setImmediate的回调,立即执行。

  • nextTick in setImmediate in setTimeout 100:执行完上边这句代码,又一个nextTick微任务,下一个宏任务前率先清空微任务

  • setTimeout 100 - 0:轮询又一次回到timers,执行100-0的回调。

  • nextTick in setTimeout 100 - 0:执行完上边这句代码,又一个nextTick微任务,下一个宏任务前率先清空微任务。

扩展:为什么有了setImmediate还要有nextTick和Promise?

一开始设计的时候,setImmediate充当了微队列的作用(虽然他不是)。设计者希望执行完poll后立即执行setImmediate(当然现在也确实是这么表现的)。所以起的名字叫Immediate,表示立即的意思。 但是后来问题是,poll里可能有N个任务连续执行,在执行期间想要执行setImmediate是不可能的。因为poll队列不停,流程不向下执行。

于是出现nextTick,真正的微队列概念。但此时,immediate的名字被占用了,所以名字叫nextTick(下一瞬间)。事件循环期间,执行任何一个队列之前,都要检查他是否被清空。其次是Promise。

面试题

最后,检验学习成果的面试题来了

async function async1() {
  console.log(&#39;async start&#39;);
  await async2();
  console.log(&#39;async end&#39;);
}

async function async2(){
  console.log(&#39;async2&#39;);
}
console.log(&#39;script start&#39;);

setTimeout(() => {
  console.log(&#39;setTimeout 0&#39;);
}, 0)

setTimeout(() => {
  console.log(&#39;setTimeout 3&#39;);
}, 3)

setImmediate(() => {
  console.log(&#39;setImmediate&#39;);
})

process.nextTick(() => {
  console.log(&#39;nextTick&#39;);
})

async1();

new Promise((res) => {
  console.log(&#39;promise1&#39;);
  res();
  console.log(&#39;promise2&#39;);
}).then(() => {
  console.log(&#39;promise 3&#39;);
});

console.log(&#39;script end&#39;);

// 答案如下
// -
// -
// -
// -
// -
// -
// -
// -
// -
// -
// -
// -






/**
script start
async start
async2
promise1
promise2
script end

nextTick
async end
promise 3

// 后边这仨的运行顺序就是验证你电脑运算速度的时候了。
速度最好(执行上边的同步代码 + 微任务 + 计时器运算用了不到0ms):
setImmediate
setTimeout 0
setTimeout 3

速度中等(执行上边的同步代码 + 微任务 + 计时器运算用了0~3ms以上):
setTimeout 0
setImmediate
setTimeout 3

速度较差(执行上边的同步代码 + 微任务 + 计时器运算用了3ms以上):
setTimeout 0
setTimeout 3
setImmediate
*/
登入後複製

思维脑图 - Node生命周期核心阶段

1圖文結合帶你搞懂Nodejs中的事件循環

圖文結合帶你搞懂Nodejs中的事件循環

更多编程相关知识,请访问:编程视频!!

以上是圖文結合帶你搞懂Nodejs中的事件循環的詳細內容。更多資訊請關注PHP中文網其他相關文章!

相關標籤:
來源:juejin.cn
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板