<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          【W(wǎng)eb技術(shù)】985- 當(dāng)聊到前端性能優(yōu)化時(shí),我們會(huì)關(guān)注什么?

          共 27827字,需瀏覽 56分鐘

           ·

          2021-06-13 13:14

          大前端  前端知識(shí)寶庫(kù)  堅(jiān)持日更

          關(guān)于這期分享內(nèi)容

          性能優(yōu)化一直是前端領(lǐng)域老生常談的問(wèn)題,系統(tǒng)的性能以及穩(wěn)定性很大程度上決定著產(chǎn)品的用戶體驗(yàn)以及產(chǎn)品所能達(dá)到的高度。而tob和toc系統(tǒng)又有著不同的業(yè)務(wù)場(chǎng)景,性能優(yōu)化也有著不用的著力點(diǎn)。本文從筆者的視角出發(fā),結(jié)合自己針對(duì)一個(gè)tob系統(tǒng)的性能優(yōu)化實(shí)踐去剖析一些大家可能共同關(guān)注的點(diǎn),爭(zhēng)取可以以小見(jiàn)大。

          關(guān)于團(tuán)隊(duì)定位

          我所在的團(tuán)隊(duì)是一個(gè)涉及業(yè)務(wù)比較復(fù)雜的的教育前端團(tuán)隊(duì),而談及在線教育,始終繞不開(kāi)在線講義,在線課件這一關(guān),我們所負(fù)責(zé)的業(yè)務(wù)旨在提供完善的在線課件解決方案:

          我們輸出的產(chǎn)品主要包括 編輯器渲染器 兩部分。

          • 編輯器 除了提供基礎(chǔ)的課件編輯制作能力外,還提供了組裝各類教育資源的能力,這些教育資源包括互動(dòng)題、cocos、pdf、ppt等。
          • 渲染器 除了提供通用渲染器來(lái)支持基礎(chǔ)課件的渲染以外,還支持接入各類教育資源的渲染器,來(lái)支持教育資源的渲染。

          關(guān)于數(shù)據(jù)結(jié)構(gòu),大致數(shù)據(jù)結(jié)構(gòu)如下所示,類似ppt的數(shù)據(jù)結(jié)構(gòu),每一頁(yè)單頁(yè)課件是一個(gè)page,每頁(yè)課件上中的文字圖片音頻視頻都是一個(gè)節(jié)點(diǎn),這些課件頁(yè)以及節(jié)點(diǎn)都是以數(shù)組的形式來(lái)維護(hù)。


          {

              pages: [

                  data: {

                      nodes: [

                          'text''image''video''staticQuestion'...

                      ]

                  }

              ]

          }

          簡(jiǎn)單了解業(yè)務(wù)之后我們才能結(jié)合具體的場(chǎng)景討論性能優(yōu)化過(guò)程中遇到的問(wèn)題。

          性能優(yōu)化歷程

          1. 3-4 雙月立項(xiàng)

          我們的項(xiàng)目規(guī)劃一般按照雙月來(lái)制定目標(biāo),34雙月我們成立課件性能優(yōu)化專項(xiàng),雙月目標(biāo)是明顯提升用戶體驗(yàn)。

          1. 針對(duì)不同問(wèn)題的解決方案

          下面我會(huì)從遇到的具體case入手,來(lái)聊一聊我們是如何解決這些問(wèn)題的。

          1. 課件列表頁(yè)卡頓

            1. 原因分析

          我們課件系統(tǒng)的數(shù)據(jù)依然采用了序列化數(shù)據(jù)存儲(chǔ)(未分頁(yè)),而我們打開(kāi)編輯器時(shí),會(huì)發(fā)請(qǐng)求拿到課件的所有內(nèi)容,課件內(nèi)容也會(huì)一股腦兒渲染在頁(yè)面上,這樣帶來(lái)的結(jié)果就是頁(yè)面的性能非常受課件體量的制約,隨著課件內(nèi)容越來(lái)越多,課件頁(yè)面達(dá)到100頁(yè)以上時(shí),系統(tǒng)的性能就已經(jīng)到達(dá)了瓶頸,具體表現(xiàn)為點(diǎn)擊切換課件頁(yè)卡頓以及列表頁(yè)滾動(dòng)卡頓。

          我們?cè)诹斜淼膙ue組件的updated 生命周期中添加了一個(gè) log 查看組件渲染次數(shù):

          updated() {

              // 查看該組件更新了多少次,勿刪

              console.log("%c left viewer rerender"'color: red;');

          },

          Vue 的 updated官網(wǎng)這樣解釋道:

          由于數(shù)據(jù)更改導(dǎo)致的虛擬 DOM 重新渲染和打補(bǔ)丁。當(dāng)這個(gè)鉤子被調(diào)用時(shí),組件 DOM 已經(jīng)更新,所以你現(xiàn)在可以執(zhí)行依賴于 DOM 的操作。然而在大多數(shù)情況下,你應(yīng)該避免在此期間更改狀態(tài)。如果要相應(yīng)狀態(tài)改變,通常最好使用 計(jì)算屬性 watcher 取而代之。

          https://cn.vuejs.org/v2/api/#updated

          于是我們發(fā)現(xiàn)點(diǎn)擊整個(gè)單個(gè)課件頁(yè)時(shí),整個(gè)左側(cè)列表都重新渲染,而每個(gè)課件頁(yè)中的log也會(huì)執(zhí)行,而且會(huì)渲染三次。

          我們初步判斷當(dāng)點(diǎn)擊單頁(yè)時(shí),組件執(zhí)行了多余的render,而在重新渲染之前虛擬dom的計(jì)算阻塞了單線程,導(dǎo)致ui假死。雖然Vue內(nèi)部對(duì)虛擬dom的計(jì)算做了很多優(yōu)化,但是在這個(gè)案例中我們看到,課件體量大時(shí),單線程依然會(huì)阻塞,我們通過(guò)performance可以進(jìn)一步證明我們的猜想。

          通過(guò) perfermance可以看到,一次點(diǎn)擊事件的處理時(shí)間達(dá)到4.16s,這一楨的時(shí)間是4500ms,在這四秒多時(shí)間內(nèi),瀏覽器是沒(méi)有任何響應(yīng)的,而通過(guò)觀察我們發(fā)現(xiàn)這段時(shí)間耗時(shí)的操作就是Vue的虛擬dom計(jì)算過(guò)程,在Bottom-up中也可以看到,耗時(shí)操作vue removeSub移除依賴的操作,還有虛擬dom patch node 的計(jì)算,這個(gè)過(guò)程是為了合并更新,這個(gè)計(jì)算堆積起來(lái)就非常耗時(shí)。

          排查到這里我將原因歸結(jié)為組件太多,不必要的更新太多,我們?nèi)ゲ榭戳艘幌马?yè)面節(jié)點(diǎn)數(shù)量

          200頁(yè)的課件全部渲染,頁(yè)面節(jié)點(diǎn)已經(jīng)到達(dá)了3w之多,而每次交互更新量巨大,瀏覽器重繪的壓力也比較大(雖然這個(gè)時(shí)間比js計(jì)算還是少很多)。

          經(jīng)過(guò)以上的排查,我們總結(jié)原因?yàn)椋篤ue的數(shù)據(jù)偵聽(tīng)?wèi)?yīng)該更新變化了的dom,但是我們點(diǎn)擊某個(gè)課件頁(yè)時(shí),由于處理Vuex的數(shù)據(jù)流的方式不太合理,使得許多組件依賴了本不需要的數(shù)據(jù),導(dǎo)致Vue判斷組件需要重新渲染。其次我們的頁(yè)面結(jié)構(gòu)過(guò)于復(fù)雜,沒(méi)有做動(dòng)態(tài)渲染或者feed流類似的分片加載策略,浪費(fèi)了很多資源。

          1. 解決方案

          基于以上的原因分析,我們嘗試了比較多的方案。其中由于Vuex數(shù)據(jù)流不合理帶來(lái)的過(guò)多rerender,由于項(xiàng)目過(guò)于復(fù)雜,涉及到了互動(dòng)題編輯器和模版編輯器,數(shù)據(jù)流的改動(dòng)風(fēng)險(xiǎn)較大,而且收益不一定明顯。于是在3-4月的優(yōu)化中,我們沒(méi)有動(dòng)現(xiàn)有的狀態(tài)管理,而是在當(dāng)前基礎(chǔ)上,力求減少每次操作的計(jì)算量和渲染量,也就是在不合理的方案下去緩解用戶體驗(yàn)問(wèn)題。我們的思路聚焦在:課件列表需要?jiǎng)討B(tài)加載,頁(yè)面節(jié)點(diǎn)越少,掛載的組件越簡(jiǎn)單,Vue的計(jì)算越快,瀏覽器渲染的速度也越快。 于是我們做了以下嘗試:

          1. IntersectionObserver

          借鑒圖片懶加載的方式,我們通過(guò)瀏覽器的 IntersectionObserver 進(jìn)行 dom 的監(jiān)聽(tīng)實(shí)現(xiàn)課件頁(yè)的懶加載

          // 在需要懶加載的節(jié)點(diǎn)上添加ref屬性為“containerNeedLazyLoad”

          // 將控制是否進(jìn)行加載的boolean變量命名為“elementNeedLoad”

          export default {

            data() {

              return {

                elementNeedLoad: false,

                elementNeedVisible: false

              };

            },

            mounted() {

              const target = this.$refs.containerNeedLazyLoad;

              const intersectionObserver = this.lazyLoadObserver(target);

              intersectionObserver.observe(target);

            },

            methods: {

              lazyLoadObserver(target) {

                return new IntersectionObserver((entries) => {

                  entries.forEach((entry) => {

                    if (entry.intersectionRatio > 0) {

                      if (this.elementNeedLoad === false) {

                        this.elementNeedLoad = true;

                        this.$nextTick(() => {

                          this.elementNeedVisible = true;

                        });

                        this.lazyLoadObserver().unobserve(target);

                      }

                    } else {

                      this.elementNeedLoad = false;

                      this.elementNeedVisible = false;

                    }

                  });

                }, {

                  threshold: [0, 0.5, 1],

                });

              },

            }

          };

          簡(jiǎn)言之就是我們給200頁(yè)課件每一頁(yè)的dom容器元素都添加了一個(gè)監(jiān)聽(tīng)器,當(dāng)該課件進(jìn)入視窗時(shí)渲染內(nèi)部的元素,在課件出視窗時(shí)注銷元素,通過(guò)v-if指令來(lái)實(shí)現(xiàn)。該方案實(shí)現(xiàn)后,實(shí)時(shí)渲染的課件數(shù)量只有視窗內(nèi)的7-8個(gè),其他課件只渲染了容器組件,頁(yè)面節(jié)點(diǎn)也少了很多,當(dāng)我們點(diǎn)擊切換課件時(shí)變得流暢了許多,那是因?yàn)槲赐耆秩镜恼n件頁(yè)Vue組件都變得「簡(jiǎn)單」了,vue的計(jì)算也會(huì)更快,點(diǎn)擊課件的時(shí)間可以在300ms內(nèi)響應(yīng)。極大優(yōu)化了體驗(yàn),于是我們滿心歡喜上線了該優(yōu)化。

          1. 動(dòng)態(tài)加載

          一天后,又有教研老師反饋?lái)?yè)面卡頓,列表頁(yè)經(jīng)常滾動(dòng)比之前更卡,于是我們?cè)俅闻挪椤I厦娴姆桨噶粝铝艘粋€(gè)比較大的問(wèn)題是,列表頁(yè)在滾動(dòng)的過(guò)程當(dāng)中,需要實(shí)時(shí)監(jiān)聽(tīng)dom,我們不懷疑瀏覽器api的性能,但是v-if指令會(huì)在值變化時(shí),執(zhí)行虛擬dom的計(jì)算并更新組件,而這個(gè)過(guò)程是在滾動(dòng)的過(guò)程中實(shí)時(shí)進(jìn)行的。

          從上面的視頻中我們可以看到在滾動(dòng)時(shí)很容易出現(xiàn)掉幀的情況,所以圖片懶加載的方案無(wú)法直接嫁接,我們需要更好的懶加載方案。

          在競(jìng)品調(diào)研過(guò)程中,我們比較關(guān)注google doc和騰訊文檔的方案。

          Google doc采用了滾動(dòng)懶加載的方式加載文檔,但是由于google的底層方案都是基于svg,方案無(wú)法復(fù)刻,但是動(dòng)態(tài)加載的方式可以借鑒。

          騰訊文檔則是采用了分頁(yè)加載,首屏渲染之后再加載其他的內(nèi)容,但是由于我們?cè)?url 上攜帶了課件 id需要定位到具體的課件頁(yè),而且數(shù)據(jù)方面沒(méi)有分頁(yè),因此該方案暫時(shí)不考慮。此外我們頁(yè)關(guān)注了某教研云的課件加載方案:

          與預(yù)想中的一樣,某教研云也采用了動(dòng)態(tài)加載的方式,可見(jiàn)這也算是長(zhǎng)列表比較常見(jiàn)的優(yōu)化手段。

          于是我們有了以下優(yōu)化思路:

          1. 默認(rèn)每張課件不渲染任何節(jié)點(diǎn),只渲染容器節(jié)點(diǎn),dom結(jié)構(gòu)會(huì)簡(jiǎn)單很多
          2. 監(jiān)聽(tīng)列表容器的滾動(dòng)事件,計(jì)算當(dāng)前視圖最中間的課件index,同時(shí)將上下各7張課件作為可渲染課件,添加滾動(dòng)監(jiān)聽(tīng)的防抖
          3. 添加可渲染課件index時(shí)通過(guò) setTimeout 逐一添加實(shí)現(xiàn)流式加載
          4. 已經(jīng)渲染的頁(yè)面在下一次滾動(dòng)中不再重復(fù)渲染
          import { on, off, mapState, mapMutations } from 'utils';

          import debounce from 'lodash/debounce';



          /**

           * 總共加載當(dāng)前課件的ppt上下若干頁(yè)課件,其他的通過(guò)滾動(dòng)延遲加載

           */

          const renderPagesBuffer = 7;

          const renderPagesBoundary = 2 * renderPagesBuffer + 1;

          const debounceTime = 400;

          const progressiveTime = 150; // 漸進(jìn)式渲染間隔時(shí)間



          /**

           * 持久化一下

           */

          const bodyHeight = document.documentElement.clientHeight || document.body.clientHeight;



          export default {

            data() {

              return {

                additionalPages: [],

                commonPages: [] // 前后兩次滾動(dòng)所需要渲染的公共頁(yè)面

              };

            },

            mounted() {

              this.observeTarget = this.$refs.pprOverviewList;

              on(this.observeTarget, 'scroll', () => {

                this.handleClearTimer();

                this.handleListScroll();

              });

              

              if (!this.renderAllPages) {

                this.updateCurrentPagesInView(new Array(renderPagesBoundary).fill(1).map((_, i) => i));

              } else {

                /* 先手動(dòng)觸發(fā)一次 */

                const timer = setTimeout(() => {

                  this.handleClearTimer();

                  this.handleListScroll();

                  clearTimeout(timer);

                }, debounceTime * 2);

              }

            },

            beforeDestroy() {

              off(this.observeTarget, 'scroll', this.handleListScroll);

            },

            computed: {

              ...mapState('editor', ['currentPagesInView']),

              pagesLength() {

                return this.pptDetail?.pages?.length || 0;

              },

              renderAllPages() {

                return this.pagesLength > renderPagesBoundary;

              }

            },

            watch: {

              additionalPages(val) {

                this.observerIndex = 1;

                this.handleRenderNextPage();

              }

            },

            methods: {

              ...mapMutations('editor', ['updateCurrentPagesInView']),

              /**

               * 增加滾動(dòng)事件的防抖設(shè)置,防止頻繁更新

               */

              handleListScroll: debounce(function() {

                const { scrollTop, scrollHeight } = this.observeTarget;

                const percent = (scrollTop + bodyHeight / 2) / scrollHeight;

                // 找到當(dāng)前滾動(dòng)位置位于頁(yè)面中心的 ppt

                const currentMiddlePage = Math.floor(this.pagesLength * percent);

                const start = Math.max(currentMiddlePage - renderPagesBuffer, 0);

                const end = Math.min(currentMiddlePage + renderPagesBuffer, this.pagesLength + 1);



                // 已經(jīng)渲染了的頁(yè)面集合(保證不重復(fù)渲染)

                const commonPages = [];

                // 滑動(dòng)之后需要新渲染的頁(yè)面集合

                const additionalPages = [];



                for (let i = start; i < end; i++) {

                  if (this.currentPagesInView.includes(i)) {

                    commonPages.push(i);

                  } else {

                    additionalPages.push(i);

                  }

                }

                this.commonPages = commonPages;

                this.additionalPages = additionalPages;

              }, debounceTime),



              handleRenderNextPage() {

                const nextPages = this.additionalPages.slice(0, this.observerIndex);

                this.updateCurrentPagesInView(

                  [...nextPages, ...this.commonPages]

                );

                this.observerIndex++;

                if (this.observerIndex >= this.additionalPages.length) {

                  this.handleClearTimer();

                } else {

                  this.observerTimer = setTimeout(() => {

                    this.animationTimer = requestAnimationFrame(this.handleRenderNextPage);

                  }, progressiveTime);

                }

              },



              handleClearTimer() {

                this.observerTimer && clearTimeout(this.observerTimer);

                this.animationTimer && cancelAnimationFrame(this.animationTimer);

              }

            }

          };

          其中需要注意的時(shí),滾動(dòng)的監(jiān)聽(tīng)添加了400ms的防抖,我們一直滾動(dòng)會(huì)感受到非常流暢,而在停止?jié)L動(dòng)開(kāi)始渲染時(shí),如果同時(shí)渲染計(jì)算得來(lái)的共15張課件,則在這些組件渲染完成之前頁(yè)面依然是卡死的狀態(tài),因此我們采用了setTimeout實(shí)現(xiàn)漸進(jìn)式渲染,宏任務(wù)的好處就是讓我們可以在每一次事件循環(huán)中插入微任務(wù),比如當(dāng)前課件正在進(jìn)行流式渲染,這時(shí)點(diǎn)擊了某張課件可以先切換再繼續(xù)渲染。

          this.observerTimer = setTimeout(() => {

            this.animationTimer = requestAnimationFrame(this.handleRenderNextPage);

          }, progressiveTime);

          另外當(dāng)再次觸發(fā)滾動(dòng)事件時(shí),需要清除此前所有的定時(shí)器重新計(jì)算,而已經(jīng)渲染了的頁(yè)面index我們存在 commonPages 數(shù)據(jù)中,在下一次計(jì)算時(shí)不進(jìn)行清除。最終優(yōu)化的效果如下:

          可以看到從用戶體驗(yàn)上,已經(jīng)解決了滾動(dòng)的卡頓問(wèn)題,同時(shí)也不會(huì)因?yàn)榻M件過(guò)多阻塞用戶的點(diǎn)擊事件。

          通過(guò)性能監(jiān)控也能看到在滾動(dòng)過(guò)程中幾乎沒(méi)有任何的計(jì)算,這也是滾動(dòng)起來(lái)十分流暢的原因。

          1. 虛擬列表

          我們前面也說(shuō)到,這是在現(xiàn)有數(shù)據(jù)流不合理的情況下的無(wú)奈之舉,而要徹底解決需要更完美的方案。由于Vue框架沒(méi)有提供React的memo或者shouldComponentUpdate類似的鉤子函數(shù),讓開(kāi)發(fā)者決定組件是否應(yīng)該重新渲染,因此在更徹底的解決方案中,由組內(nèi)另一位同學(xué)主導(dǎo)設(shè)計(jì),我們嘗試用react虛擬列表進(jìn)行重構(gòu)。

          長(zhǎng)列表的終極優(yōu)化方案還是要走向虛擬列表,無(wú)論是百度貼吧中期的技術(shù)重構(gòu),還是今日頭條的feed流,都曾基于該方案做過(guò)探索。

          虛擬列表是一種根據(jù)滾動(dòng)容器元素的可視區(qū)域來(lái)渲染長(zhǎng)列表數(shù)據(jù)中某一個(gè)部分?jǐn)?shù)據(jù)的技術(shù),具體在實(shí)現(xiàn)的時(shí)候,需要一個(gè)用于滾動(dòng)監(jiān)聽(tīng)的虛擬容器,和一個(gè)用作元素渲染的真實(shí)容器。

          虛擬列表通過(guò)計(jì)算滾動(dòng)視窗,每次只渲染部分元素,既減少了首屏壓力,長(zhǎng)時(shí)間加載也不會(huì)有更多的性能負(fù)擔(dān)。虛擬列表中的元素都是絕對(duì)定位的,無(wú)論列表有多長(zhǎng),實(shí)際渲染的元素始終保持在可控范圍內(nèi)。

          前端領(lǐng)域各個(gè)社區(qū)也有了比較成熟的解決方案,react-virtualized、react-window 以及 vue-virtualize-list 等等,關(guān)于虛擬列表原理的敘述可以參考以下文章,這里限于篇幅不再贅述:

          https://github.com/dwqs/blog/issues/70

          import { SortableContainer } from 'react-sortable-hoc';

          import { areEqual, VariableSizeList as List } from 'react-window';

          import React, {

            useCallback,

            useEffect,

            useMemo,

            useRef,

            useState,

          } from 'react';



          export const ReactVirtualList: React.FC<any> = (props) => {

            const list = useRef(null);

            const initialScrolled = useRef(false);

            const [dragging, setDragging] = useState(-1);

            const { pages, currentPageId, selectedPagesId } = props;

            const pageGroups = buildPageGroups(pages);



            useEffect(() => {

              props.onReady({ scrollToTargetPage });

            }, []);



            useEffect(() => {

              list.current.resetAfterIndex(0);

            }, [pages]);



            useEffect(() => {

              // 進(jìn)入頁(yè)面定位到選擇頁(yè)面

              if (!initialScrolled.current && pages.length > 0) {

                scrollToCurrentPage();

                initialScrolled.current = true;

              }

            }, [pages]);



            const scrollToCurrentPage = () => {

              scrollToTargetPage(currentPageId, pages);

            };

            

            // 渲染列表的每一項(xiàng)

            return <SomeThing />

           } 

          同時(shí)由于最初我們縮略圖的元素渲染器采用了跟用戶操作區(qū)的同一個(gè)渲染組件,每個(gè)元素上都有很多事件監(jiān)聽(tīng),新版本我們也封裝了基于 react 的純 ui 元素渲染器 ,去掉了無(wú)用的事件監(jiān)聽(tīng),簡(jiǎn)化了dom結(jié)構(gòu)。

          兩者結(jié)合就成了新版本的縮略圖列表,目前已經(jīng)上線完成,從各項(xiàng)指標(biāo)以及用戶體驗(yàn)來(lái)看,提升還是非常大的,其中更明顯的拖拽排序,相較于此前的拖拽排序用戶體驗(yàn)要好得多。

          1. 內(nèi)存泄漏

          同樣是上述課件,經(jīng)過(guò)上述的優(yōu)化,頁(yè)面的節(jié)點(diǎn)數(shù)量已經(jīng)少了很多,而且卡頓問(wèn)題也得到了改善。但是當(dāng)我們不斷滾動(dòng)左側(cè)預(yù)覽圖時(shí),一段時(shí)間之后還是會(huì)卡頓甚至卡死。此時(shí)要么是 cpu占用率過(guò)高導(dǎo)致頁(yè)面無(wú)法響應(yīng),要么出現(xiàn)了內(nèi)存泄漏問(wèn)題,經(jīng)過(guò)排查發(fā)現(xiàn),雖然頁(yè)面中的節(jié)點(diǎn)在懶加載過(guò)程中會(huì)注銷,但是這些節(jié)點(diǎn)依然會(huì)被保存在內(nèi)存當(dāng)中,一直沒(méi)有釋放,甚至達(dá)到10w之多,內(nèi)存占用也一直線性增長(zhǎng),我們需要針對(duì)這一些內(nèi)存中的節(jié)點(diǎn)進(jìn)行優(yōu)化,處理內(nèi)存泄漏問(wèn)題。

          我們通過(guò)內(nèi)存快照可以看到,標(biāo)記為 Detached 也就是脫離文檔流的節(jié)點(diǎn)依然存儲(chǔ)在內(nèi)存當(dāng)中,其中 DIVElement 有兩萬(wàn)多個(gè),展開(kāi)發(fā)現(xiàn)都是 element-render (我們課件元素渲染器)中的元素,比如帶有 render-wrap 或者 selection-zone 的這些類(都是我們項(xiàng)目中掛載在dom上的類名)。所以判斷這個(gè)組件存在內(nèi)存泄露問(wèn)題。

          排查的過(guò)程中,一度懷疑是vue 的 v-if 造成的,在 vue的官網(wǎng)有關(guān)于 v-if 內(nèi)存泄漏的相關(guān)內(nèi)容。

          https://cn.vuejs.org/v2/cookbook/avoiding-memory-leaks.html

          我嘗試了通過(guò)手動(dòng)掛載組件執(zhí)行$mount,在課件滑到視圖之外時(shí)手動(dòng)注銷,依然沒(méi)有作用,甚至嘗試實(shí)現(xiàn)自定義指令 v-clean-node,在懶加載過(guò)程中動(dòng)態(tài)注銷節(jié)點(diǎn)。但事實(shí)證明,節(jié)點(diǎn)是已經(jīng)從dom結(jié)構(gòu)中注銷了的,只是對(duì)應(yīng)的dom片段依然保存在內(nèi)存當(dāng)中,而且筆者也沒(méi)有找到可以手動(dòng)釋放內(nèi)存的方法,至少在瀏覽器環(huán)境還沒(méi)有辦法辦到。這里走了很多彎路,而思考的方向或許也能成為一些反面案例,不過(guò)性能優(yōu)化這條路需要做的也是勇敢嘗試,敢于試錯(cuò),最終總能找到一個(gè)相對(duì)較優(yōu)的解決辦法。

          我們換個(gè)思考的方向,既然不能手動(dòng)釋放內(nèi)存,就去迎合v8內(nèi)存管理的原理,代碼怎樣寫(xiě)才能保證內(nèi)存被正常釋放。我們想到的就是一直被引用的變量,其次就是未被注銷的事件監(jiān)聽(tīng)。

          接下來(lái)的排查采用了打斷點(diǎn)和注釋代碼的笨辦法,逐漸縮小排查范圍,最終鎖定在了渲染富文本所使用的 render 組件。

          這個(gè)組件用到了兄弟團(tuán)隊(duì)提供的render 庫(kù),而在 node_modules 是編譯后的 es5代碼,可讀性還不錯(cuò),于是我通過(guò)斷點(diǎn)在在源碼中進(jìn)行調(diào)試,手動(dòng)追蹤調(diào)用棧。

          在最終渲染的方法中,排查找到了這樣一行代碼:

          這里每個(gè)富文本渲染都會(huì)添加一個(gè)body resize的事件監(jiān)聽(tīng),而筆者并沒(méi)有找到相關(guān)unobserve的邏輯。類似這種事件監(jiān)聽(tīng)的代碼如果沒(méi)有取消監(jiān)聽(tīng),很容易造成內(nèi)存泄漏,注釋這一行代碼之后重啟項(xiàng)目,系統(tǒng)的內(nèi)存可以正常回收了,最終確定是由這個(gè)sdk導(dǎo)致了內(nèi)存泄漏,后續(xù)兄弟團(tuán)隊(duì)的同學(xué)也協(xié)助解決了這一問(wèn)題,重新發(fā)了一個(gè)正式包。

          通過(guò)測(cè)試發(fā)現(xiàn)內(nèi)存已經(jīng)可以正常釋放。

          這里的內(nèi)存泄漏可以用以下示意圖概括:


          未被釋放的事件監(jiān)聽(tīng)會(huì)導(dǎo)致對(duì)應(yīng)的組件在卸載時(shí)并未被釋放,因此我們的內(nèi)存中會(huì)有這些 vue組件。

          日常項(xiàng)目開(kāi)發(fā)的過(guò)程中,內(nèi)存優(yōu)化需要持續(xù)關(guān)注,我們課件編輯器的內(nèi)存占用大概100M左右,需要在后續(xù)的開(kāi)發(fā)過(guò)程中持續(xù)優(yōu)化

          1. 點(diǎn)擊預(yù)覽按鈕之后頁(yè)面操作卡頓

          這是一個(gè)非常有趣的案例,課件的預(yù)覽是在當(dāng)前頁(yè)面打開(kāi)一個(gè)彈窗嵌入預(yù)覽頁(yè)面的iframe,每次點(diǎn)擊預(yù)覽之后回到編輯器總會(huì)出現(xiàn)或多或少的卡頓現(xiàn)象。

          這是因?yàn)轭A(yù)覽頁(yè)和編輯器的域名相同,因此打開(kāi)iframe時(shí)共享了同一進(jìn)程。

          iframe 作為升級(jí)版的 frame,一般來(lái)說(shuō)都會(huì)被認(rèn)為和上層的 parent 容器處在同一個(gè)進(jìn)程中,他們會(huì)擁有父容器的一個(gè)子上下文 BrowserContext。在這種情況下,iframe 當(dāng)中的 js 運(yùn)行時(shí)便會(huì)阻塞外部的 js 運(yùn)行,特別是當(dāng)如果 iframe 中的代碼質(zhì)量不高而導(dǎo)致性能問(wèn)題時(shí),外層運(yùn)行的容器會(huì)受到相當(dāng)大的影響。這顯然是我們不愿意看到的,因?yàn)?webview 中的內(nèi)容僅僅會(huì)作為 IDE 拓展機(jī)制的一部分,我們不希望看到我們的外部 UI 和程序被 iframe 阻塞從而導(dǎo)致性能表現(xiàn)不佳。

          iframe 線程

          幸運(yùn)的是,Chrom 在 67 版本之后默認(rèn)開(kāi)啟了 Site Isolation。基于它的描述,如果 iframe 中的域名和當(dāng)前父域名不同(也就是大家熟悉的跨域情況),那么這個(gè) iframe 中的渲染內(nèi)容就會(huì)被放在兩個(gè)不同的渲染進(jìn)程中。而我們只需要將 IDE 主應(yīng)用的頁(yè)面掛在域名A下,而同時(shí)將 iframe 的的頁(yè)面掛在域名B下,那么這個(gè) iframe 的進(jìn)程就和主進(jìn)程分開(kāi)了。在這種模型下,iframe 和主進(jìn)程僅僅能通過(guò) postMessage 和 message 事件進(jìn)行數(shù)據(jù)通訊。但是在上面的模型中,仍然有一點(diǎn)需要注意。基于 Site Isolation 的特性,同一個(gè)頁(yè)面中如果有多個(gè),擁有同一個(gè)域名的多個(gè) iframe 之間是共享進(jìn)程的,因此他們?nèi)匀粫?huì)互相影響性能。如果某個(gè)業(yè)務(wù)場(chǎng)景需要一個(gè)更為獨(dú)立的 iframe 進(jìn)程,它必須和其他 iframe 擁有不同的域名。

          我們?cè)陧?xiàng)目中分別嵌入了百度首頁(yè)和我們課件渲染頁(yè)面,發(fā)現(xiàn)一級(jí)域名相同時(shí)iframe和當(dāng)前頁(yè)面總是會(huì)共享進(jìn)程id,無(wú)論嵌入頁(yè)面的性能如何,對(duì)當(dāng)前頁(yè)面都會(huì)有或多或少的影響。因此我們有了以下解決方案:

          1. 預(yù)覽頁(yè)面部署到新的域名上,兩者不共享進(jìn)程
          2. 通過(guò)a標(biāo)簽打開(kāi)新的頁(yè)面進(jìn)行預(yù)覽,需要注意的是a標(biāo)簽需要加上 rel="noopener"屬性,切斷進(jìn)程聯(lián)系
          <a

            v-if="showOpenEntry"

            class="intro-step3 preview-wrap"

            rel="noopener"

            target="__blank"

            :disabled="!showEditArea"

            :style="{ color: !showEditArea ? 'lightgray' : '#515a6e' }"

            :href="pageShareUrl"

          >

            <lego-icon type="preview" size="16" />

          </a>

          目前的優(yōu)化中采用了第二種跳轉(zhuǎn)的方式作為臨時(shí)方案。

          1. 添加動(dòng)畫(huà)時(shí)間過(guò)長(zhǎng)

          有這樣一個(gè)場(chǎng)景是老師需要給多個(gè)元素同時(shí)添加動(dòng)畫(huà),但是頁(yè)面需要幾秒鐘響應(yīng),對(duì)于用戶來(lái)說(shuō)就是出現(xiàn)了卡頓。

          我們依然通過(guò)performance排查,確定了動(dòng)畫(huà)表單的渲染阻塞了ui的更新,同樣涉及到長(zhǎng)列表,但此處沒(méi)有課件列表復(fù)雜,而且課件頁(yè)都渲染了一個(gè)固定高度的容器,此處每個(gè)動(dòng)畫(huà)表單高度都不一樣,因此我們采用另一種懶加載的渲染方式:

          export default {

            data() {

              return {

                // 當(dāng)前渲染動(dòng)畫(huà)數(shù)量

                nextRenderQuantity: 0

              };

            },

            

            computed: {

              animationLength() {

                return this.animationConfigsUnderActiveTab.length;

              },

              renderAnimationList() {

                // 從原數(shù)組中切割

                return this.animationConfigsUnderActiveTab.slice(0, this.nextRenderQuantity);

              }

            },



            watch: {

              animationLength: {

                handler() {

                  this.handleRenderProgressive();

                },

                immediate: true,

              }

            },



            beforeDestroy() {

              this.timer && cancelAnimationFrame(this.timer);

            },

            

            methods: {

              /**

               * 動(dòng)畫(huà)表單的漸進(jìn)式渲染,每一幀多渲染一個(gè)

               */

              handleRenderProgressive() {

                this.timer && cancelAnimationFrame(this.timer);

                if (this.nextRenderQuantity < this.animationConfigsUnderActiveTab.length) {

                  this.nextRenderQuantity += 1;

                  this.timer = requestAnimationFrame(this.handleRenderProgressive);

                }

              },

            }

          };

          通過(guò) requestAnimationFrame 每一幀添加一個(gè)動(dòng)畫(huà),也就是40個(gè)元素同時(shí)添加動(dòng)畫(huà),需要40x16 = 640ms渲染完表單,而在這個(gè)時(shí)間之前,頁(yè)面已經(jīng)及時(shí)作出了響應(yīng),老師在使用的時(shí)候就不會(huì)覺(jué)得卡頓了。

          優(yōu)化前后的響應(yīng)時(shí)間從2.35s => 370ms,優(yōu)化效果比較顯著。

          總結(jié):不要讓你的js邏輯阻塞了ui的渲染。

          1. 其他的優(yōu)化

          其他的一些常見(jiàn)的項(xiàng)目?jī)?yōu)化就不在此贅述了,無(wú)論什么樣的業(yè)務(wù)場(chǎng)景,tob還是toc系統(tǒng),大家可能都曾在以下優(yōu)化方向上摸爬滾打過(guò)。

          1. 路由懶加載
          2. 靜態(tài)資源緩存
          3. 打包體積優(yōu)化
          4. 較大第三方庫(kù)借助cdn
          5. 編碼優(yōu)化:長(zhǎng)數(shù)組遍歷善用 for 循環(huán)等
          6. 可能的預(yù)編譯優(yōu)化

          深入框架,尋找性能優(yōu)化方向

          Vue 的懶人哲學(xué) vs React 暴力美學(xué)

          在性能優(yōu)化的路上越走越偏,我也深深感受到前端工具帶來(lái)的便利和過(guò)分依賴前端框架所帶來(lái)的所謂的 side effect。vue 和 react 作為如今最火的兩個(gè)框架,各自有著其獨(dú)特的魅力,而性能優(yōu)化的同時(shí)我們始終繞不開(kāi)框架的底層原理。

          Vue 的懶人哲學(xué):

          曾經(jīng)的一次分享中我們提到了vue所謂的懶人哲學(xué) ,也就是說(shuō) vue 框架內(nèi)部為你做了太多優(yōu)化工作。

          我們知道Vue會(huì)對(duì)組件中需要追蹤的狀態(tài),將其轉(zhuǎn)化為getter和setter進(jìn)行數(shù)據(jù)代理,構(gòu)建視圖和數(shù)據(jù)層的依賴,這就是ViewModel 這一層。而正是由于vue精確到原子級(jí)別的數(shù)據(jù)偵聽(tīng)使得其對(duì)數(shù)據(jù)十分敏感,任何數(shù)據(jù)的改變,vue都能知道這個(gè)數(shù)據(jù)所綁定的視圖,在下一次dom diff時(shí),他能精確知道哪些dom該渲染,哪些保持不動(dòng)。而vue的這個(gè)原理也是他升級(jí)Vue3時(shí)進(jìn)行更高效的預(yù)編譯優(yōu)化的前提條件,感興趣的同學(xué)可以跟我探討下曾經(jīng)的分享,這其中也聊到了 Vue。

          https://zhuanlan.zhihu.com/p/158880026

          但是最大問(wèn)題在于,vue 更新視圖恰好不多不少的前提是,你的數(shù)據(jù)流十分干凈,沒(méi)有多余的數(shù)據(jù)更新,否則「敏感」的vue會(huì)以為更多的組件需要重新渲染,這也是目前我們課件編輯器的問(wèn)題所在,項(xiàng)目體量越來(lái)越大,幾乎沒(méi)有幾個(gè)開(kāi)發(fā)者可以保證自己所維護(hù)的狀態(tài)管理干凈透明,而一旦有不合理的數(shù)據(jù)更新,組件的重新渲染是無(wú)法從中攔截的,因此用 Vue 可以讓你「懶」一點(diǎn),也需要你寫(xiě)代碼時(shí)「小心」一點(diǎn)。而對(duì)比react,兩者底層設(shè)計(jì)的不同導(dǎo)致在遇到此類問(wèn)題時(shí),我們可能需要不一樣的思考方向。

          現(xiàn)在在知乎上還能翻到一些尤大關(guān)于 react 性能問(wèn)題的理解。

          尤大說(shuō)的比較通俗易懂,而且也確實(shí)直指react框架的要害,其中所提到的 react 把大量?jī)?yōu)化責(zé)任丟給開(kāi)發(fā)者,相信大家都有所感受。

          React 的暴力美學(xué):

          與Vue不同的是,React對(duì)數(shù)據(jù)天然不敏感,框架不關(guān)心你更新了多少數(shù)據(jù),乃至更新了多少臟數(shù)據(jù),數(shù)據(jù)與dom結(jié)構(gòu)也沒(méi)有vue那種依賴關(guān)系,你只需要通過(guò) setState 告訴我我應(yīng)該渲染哪些組件即可。與 Vue 相比,react的處理方式既優(yōu)雅又暴力,而且從開(kāi)發(fā)者的角度來(lái)審視,react的這種設(shè)計(jì)真的減少了太多的心理負(fù)擔(dān),而作為初接入react的開(kāi)發(fā)者來(lái)說(shuō),你不會(huì)因?yàn)槎喔铝藬?shù)據(jù)導(dǎo)致過(guò)多的rerender 而抓耳撓腮,你要做的就是借助框架本身去消除這些副作用,眾所周知react正好提供了這些能力:

          • Reat.memo
          • PureComponent
          • shouldComponentUpdate

          你需要的,都能借助框架或者第三方工具做到。

          談到這里,不妨具體說(shuō)說(shuō)框架如何避免不必要的渲染問(wèn)題:

          以 react context 為例,如果我們?cè)陧?xiàng)目中所有的狀態(tài)管理都放在一個(gè) context 中,那么在使用時(shí)總會(huì)引起不必要的渲染。而在開(kāi)發(fā)過(guò)程中如何避免,不同開(kāi)發(fā)者都有不同的心得。

          const AppContext = React.createContext(null);



          const App = () => {

            const [count, setCount] = useState(0); // 經(jīng)常變的

            const [name, setName] = useState('Mike'); // 不經(jīng)常變的

            return (

              <AppContext.Provider value={{

                count,

                setCount,

                name,

                setName

              }}>

                <ComponentA />

                <ComponentB />

              </AppContext.Provider>

            )

          }



          const ComponentA = () => {

            console.log('這是組件A');

            const context = useContext(Context);

            return (

              <div>{context.name}</div>

            )

          }



          const ComponentB = () => {

            console.log('這是組件B')

            const context = useContext(Context);

            return (

              <div>

                {context.count}

                <button

                  onClick={() => {

                    context.setCount((c) => c + 1)

                  }}

                >

                  SetCount

                </button>

              </div>

            )

          }

          在這個(gè) demo中,我們?cè)陧攲幼⑷肓?Context 做狀態(tài)管理,同時(shí)有一個(gè)經(jīng)常改變的狀態(tài)count 和一個(gè)不經(jīng)常改變的狀態(tài) name,組件A和組件B分別引用了 name 和 count 這兩個(gè)狀態(tài)。

          我們?cè)邳c(diǎn)擊 SetCount 時(shí),調(diào)用了全局上下文 中的方法,同時(shí)觀測(cè)到A B兩個(gè)組件都會(huì)重新渲染,而實(shí)際上我們的組件 A只用到了 name 這個(gè)狀態(tài),是不應(yīng)該重新渲染的。這里的數(shù)據(jù)流其實(shí)非常「干凈」,沒(méi)有多余的引用,如果是 Vue,它會(huì)追蹤依賴而避免組件 A 的渲染,react 卻沒(méi)有做到。而作為 react 開(kāi)發(fā)者,如果放任這種不必要的 rerender不管,那正如尤大所說(shuō), react 應(yīng)用的性能確實(shí)會(huì)遇到瓶頸,好在 react 給了開(kāi)發(fā)者足夠的發(fā)揮空間,大多開(kāi)發(fā)者遇到此類場(chǎng)景,反手就是一個(gè) context 拆分優(yōu)雅解決:

          const AppContext = React.createContext(null);

          const AppContext2 = React.createContext(null);



          const App = () => {

            const [count, setCount] = useState(0);

            const [name, setName] = useState('Mike');

            return (

              <AppContext.Provider value={{

                name,

                setName

              }}>

                <AppContext2.Provider value={{

                  count,

                  setCount

                }}>

                  <ComponentA />

                  <ComponentB />

                </AppContext2.Provider>

              </AppContext.Provider>

            )

          }



          const ComponentA = () => {

            console.log('這是組件A');

            const context = useContext(Context);

            return (

              <div>{context.name}</div>

            )

          }



          const ComponentB = () => {

            console.log('這是組件B')

            const context = useContext(Context);

            return (

              <div>

                {context.count}

                <button

                  onClick={() => {

                    context.setCount((c) => c + 1)

                  }}

                >

                  SetCount

                </button>

              </div>

            )

          }

          這里我們將兩個(gè)狀態(tài)拆分進(jìn)不同的 context 中,此時(shí)再調(diào)用 setCount 方法,就不會(huì)影響到組件 A 重新渲染了。這也是我們實(shí)際項(xiàng)目開(kāi)發(fā)中最常見(jiàn)的解決方案。但是項(xiàng)目體量越來(lái)越大時(shí),這種模塊的拆分會(huì)變得很繁瑣,類似 Vuex 模塊的拆分一樣,我們開(kāi)發(fā)一個(gè)新功能,也總是不愿意在 vuex 中新開(kāi)辟一個(gè)模塊,寫(xiě)更多的文件以及 getters mutations。所以在這個(gè)例子中我們也可以通過(guò) useMemo 來(lái)解決:

          const ComponentA = () => {

            console.log('這是組件A');

            const context = useContext(Context);



            const memoResult = useMemo(

              () => {

                <div>{context.name}</div>

              },

              [context.name]

            )



            return memoResult;

          }

          我們?cè)诮M件 A 中,組件內(nèi)容用 useMemo 包裹,將其制造為一個(gè)緩存對(duì)象,這里的 useMemo 不去緩存 state,因?yàn)槲覀冋{(diào)用了頂層方法 setCount 引起 state immutable 更新 -> 進(jìn)而 name 更新(引用地址變化),在頂層組件中緩存 state 其實(shí)并沒(méi)有什么用,所以在這個(gè)案例中 useMemo 只能用來(lái)緩存組件。

          當(dāng)然,我們不能每個(gè)組件都通過(guò) useMemo 來(lái)處理,很多時(shí)候只是平添開(kāi)銷。因此 react 團(tuán)隊(duì)所提出的 context selectors 才是解決類似案例的最佳選擇:

          https://github.com/reactjs/rfcs/pull/119

          通過(guò) selector 機(jī)制,開(kāi)發(fā)者在使用 Contexts 時(shí),可以選擇需要相應(yīng)的 Context data,從而規(guī)避掉「不關(guān)心」的 Context data 變化所觸發(fā)的渲染。這跟我們手動(dòng)拆分 context 所實(shí)現(xiàn)的功能如出一轍,總的來(lái)說(shuō)優(yōu)化思路還是比較一致的。

          react 更多案例可以參考:

          https://codesandbox.io/s/react-codesandbox-forked-s9x6e?file=/src/Demo1/index.js

          聊到這里我們發(fā)現(xiàn),當(dāng)使用框架作為開(kāi)發(fā)工具來(lái)解決問(wèn)題時(shí),如果產(chǎn)生了副作用,react開(kāi)發(fā)者有很多方式可以抵消這個(gè)副作用,而相對(duì)來(lái)說(shuō) vue 以及vue生態(tài)圈所能提供的解決方案就比較少,正如前面我們遇到的那些bad case一樣,我們可能需要從一些比較偏的角度去思考才能解決這類問(wèn)題。

          題外話(個(gè)人觀點(diǎn)):

          個(gè)人認(rèn)為 Vue 做大型項(xiàng)目有著天然的弊端,由于遞歸實(shí)現(xiàn)了精確數(shù)據(jù)偵聽(tīng),使得其產(chǎn)生了過(guò)多的訂閱對(duì)象,而正如前面所說(shuō),一旦數(shù)據(jù)流不合理,多余的更新不可逆,而過(guò)多的偵聽(tīng)對(duì)象對(duì)系統(tǒng)內(nèi)存也是一個(gè)考驗(yàn)。令一點(diǎn)就是筆者的自我感受,Vue 項(xiàng)目開(kāi)發(fā)在組件化不如 React 來(lái)得清澈透明,單文件組件大了之后,可讀性也比較差,而react 有社區(qū)加持,有 Redux 和 Saga 進(jìn)行狀態(tài)管理,上手曲線雖然略高,但是代碼規(guī)范度極高,狀態(tài)管理效果極好,適合團(tuán)隊(duì)開(kāi)發(fā)。反觀 vue, 做小型項(xiàng)目卻有著天然優(yōu)勢(shì)(為什么?),因此每個(gè)項(xiàng)目在前期都要著重分析業(yè)務(wù)場(chǎng)景,做好項(xiàng)目規(guī)劃和技術(shù)選型。個(gè)人觀點(diǎn),希望各位同學(xué)指出不足,理性討論。

          以上是筆者在我們課件編輯器的項(xiàng)目中一些優(yōu)化實(shí)踐,不同場(chǎng)景有不同的解決方案,希望大家也可以留言給到一些建議和幫助,讓我們課件團(tuán)隊(duì)可以打磨出更好的產(chǎn)品。

          關(guān)于性能優(yōu)化的建議

          1. 面向用戶,了解用戶真正的痛點(diǎn)

          縮小產(chǎn)研團(tuán)隊(duì)和用戶之間對(duì)產(chǎn)品理解的gap。

          1. 發(fā)現(xiàn)問(wèn)題,問(wèn)題就解決了一半

          發(fā)現(xiàn)問(wèn)題,也需要發(fā)現(xiàn)問(wèn)題的根源,性能問(wèn)題的背后,往往是編碼的不合理以及工具的不合理應(yīng)用。

          1. 歸納總結(jié),觸類旁通。

          相似的問(wèn)題千篇一律,有趣的方案各有各的特色,每次性能優(yōu)化之后,歸納總結(jié)總能帶來(lái)更多的收獲。

          1. JavaScript 重溫系列(22篇全)
          2. ECMAScript 重溫系列(10篇全)
          3. JavaScript設(shè)計(jì)模式 重溫系列(9篇全)
          4. 正則 / 框架 / 算法等 重溫系列(16篇全)
          5. Webpack4 入門(mén)(上)|| Webpack4 入門(mén)(下)
          6. MobX 入門(mén)(上) ||  MobX 入門(mén)(下)
          7. 120+篇原創(chuàng)系列匯總

          回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~

          點(diǎn)擊“閱讀原文”查看 120+ 篇原創(chuàng)文章

          瀏覽 60
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評(píng)論
          圖片
          表情
          推薦
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  日韩成人无码人妻 | 日韩插穴| 天堂中文网 | 91人妻无码成人精品一区91 | 国产精品美女一区 |