<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>

          Islands 架構(gòu)原理和實(shí)踐

          共 11608字,需瀏覽 24分鐘

           ·

          2022-10-21 14:20

          Islands 架構(gòu)是今年比較火的一個(gè)話題,目前社區(qū)一些比較知名的新框架如 Fresh、Astro 都是 Islands 架構(gòu)的典型代表。本文將給大家介紹 Islands 架構(gòu)誕生的來(lái)龍去脈,分析它相比于 Next.js、Gatsby 等傳統(tǒng)方案的優(yōu)勢(shì),并且剖析社區(qū)相關(guān)框架的實(shí)現(xiàn)原理,以及分享筆者在這個(gè)方向上的一些實(shí)踐。

          MPA 和 SPA 的取舍

          MPA 和 SPA 是構(gòu)建前端頁(yè)面常見(jiàn)的兩種方式,理解 MPA 和 SPA 的區(qū)別和不同場(chǎng)景的取舍是理解 Islands 架構(gòu)的關(guān)鍵。

          概念

          MPA(Multi-page application) 即多頁(yè)應(yīng)用,是從服務(wù)器加載多個(gè) HTML 頁(yè)面的應(yīng)用程序。每個(gè)頁(yè)面都彼此獨(dú)立,有自己的 URL。當(dāng)單擊 a 標(biāo)簽鏈接導(dǎo)航到另一個(gè)頁(yè)面時(shí),瀏覽器將向服務(wù)器發(fā)送請(qǐng)求并加載新頁(yè)面。例如,傳統(tǒng)的模板技術(shù)如JSP、Python、Django、PHP、Laravel 等都是基于 MPA 的框架,包括目前比較火的 Astro 也是采用的 MPA 方案。

          SPA(Single-page application) 即單頁(yè)應(yīng)用,它只有一個(gè)不包含具體頁(yè)面內(nèi)容的 HTML,當(dāng)瀏覽器拿到這份 HTML 之后,會(huì)請(qǐng)求頁(yè)面所需的 JavaScript 代碼,通過(guò)執(zhí)行 JavaScript 代碼完成 DOM 樹(shù)的構(gòu)建和 DOM 的事件綁定,從而讓頁(yè)面可以交互。如現(xiàn)在使用的大多數(shù) Vue、React 中后臺(tái)應(yīng)用都是 SPA 應(yīng)用。

          對(duì)比

          1. 性能

          在 MPA 中,服務(wù)器將響應(yīng)完整的 HTML 頁(yè)面給瀏覽器,但是 SPA 需要先請(qǐng)求客戶端的 JS Bundle 然后執(zhí)行 JS 以渲染頁(yè)面。因此,MPA 中的頁(yè)面的首屏加載性能比 SPA 更好。

          但 SPA 在后續(xù)頁(yè)面加載方面有更好的性能和體驗(yàn)。因?yàn)?SPA 在完成首屏加載之后,在訪問(wèn)其它的頁(yè)面時(shí)只需要?jiǎng)討B(tài)加載頁(yè)面的一部分組件,而不是整個(gè)頁(yè)面。而且,當(dāng)頁(yè)面發(fā)生跳轉(zhuǎn)時(shí),SPA 不會(huì)重新加載頁(yè)面,對(duì)用戶更友好。
          2. SEO
          MPA 中服務(wù)端會(huì)針對(duì)每個(gè)頁(yè)面返回完整的 HTML 內(nèi)容,對(duì) SEO 更加友好;而 SPA 的頁(yè)面內(nèi)容則需要執(zhí)行 JS 才能拉取到,不利于 SEO。
          3. 路由
          MPA 在瀏覽器側(cè)其實(shí)不需要路由,每個(gè)頁(yè)面都在服務(wù)端都有一份 URL 地址,瀏覽器拿到 URL 直接請(qǐng)求服務(wù)端即可。
          但 SPA 則不同,它需要 JS 掌管后續(xù)所有路由跳轉(zhuǎn)的邏輯,因此會(huì)引入一些路由方案來(lái)管理前端的路由,比如基于 hashchange 事件或者瀏覽器 history API 來(lái)實(shí)現(xiàn)。
          4. 狀態(tài)管理
          除了路由,SPA 另外一個(gè)復(fù)雜的點(diǎn)在于狀態(tài)管理。SPA 當(dāng)中所有路由的狀態(tài)都是由 JS 進(jìn)行管理,在不同的路由進(jìn)行跳轉(zhuǎn)時(shí)通過(guò) JS 代碼進(jìn)行一些狀態(tài)的流轉(zhuǎn),在頁(yè)面的規(guī)模越來(lái)越大的時(shí)候,狀態(tài)管理就變得越來(lái)越復(fù)雜了。因此,社區(qū)也誕生了不少的狀態(tài)管理方案,如傳統(tǒng)的 Redux、社區(qū)新秀 Valtio、Zustand 包括字節(jié)自研的 Reduck,都是為了解決 SPA 狀態(tài)管理的問(wèn)題,一方面降低操作的復(fù)雜度、另一方面引入一些規(guī)范和限制(比如 Redux 中的 action 機(jī)制)來(lái)提高項(xiàng)目可維護(hù)性。
          而 MPA 則會(huì)簡(jiǎn)單很多,因?yàn)槊總€(gè)頁(yè)面之間都是相互獨(dú)立的,不需要在前端做復(fù)雜的狀態(tài)管理。

          取舍

          總而言之,MPA 有更好的首屏性能,SPA 在后續(xù)頁(yè)面的訪問(wèn)中有更好的性能和體驗(yàn),但 SPA 也帶來(lái)了更高的工程復(fù)雜度、略差的首屏性能和 SEO。這樣就需要在不同的場(chǎng)景中做一些取舍。

          不過(guò),MPA 和 SPA 也并不是完全割裂的,兩者也是能夠有所結(jié)合的,比如 SSR/SSG 同構(gòu)方案就是一個(gè)典型的體現(xiàn),首先框架側(cè)會(huì)在服務(wù)端生成完整的 HTML 內(nèi)容,并且同時(shí)注入客戶端所需要的 SPA 腳本。這樣瀏覽器會(huì)拿到完整的 HTML 內(nèi)容,然后執(zhí)行客戶端的腳本事件的綁定(這個(gè)過(guò)程也叫 hydrate),后續(xù)路由的跳轉(zhuǎn)由 JS 來(lái)掌管。當(dāng)下很多的框架都是采用這樣的方案,比如 Next.js、Gatsby、公司內(nèi)部的 Eden SSR、Modern.js。

          但實(shí)際上,把 MPA 和 SPA 結(jié)合的方案也并不是完美無(wú)缺的,主要的問(wèn)題在于這類(lèi)方案仍然會(huì)下載全量的客戶端 JS 及執(zhí)行全量的組件 Hydrate 過(guò)程,造成頁(yè)面的首屏 TTI 劣化。

          我們可以試想對(duì)于一個(gè)文檔類(lèi)型的站點(diǎn),其實(shí)里面的大多數(shù)組件是不需要交互的,主要以靜態(tài)頁(yè)面的渲染為主,因此直接采用 MPA 方案是一個(gè)比 MPA + SPA 更好的一個(gè)選擇。進(jìn)一步講,對(duì)于更多的輕交互、重內(nèi)容的應(yīng)用場(chǎng)景,MPA 也依然是一個(gè)更好的方案。

          由于頁(yè)面中有時(shí)仍然不可避免的需要一些交互的邏輯,那放在 MPA 中如何來(lái)完成呢?這就是 Islands 架構(gòu)所要解決的問(wèn)題。

          什么是 Islands 架構(gòu)?

          Islands 架構(gòu)模型早在 2019 年就被提出來(lái)了,并在 2021 年被 Preact 作者Json Miller 在 Islnads Architecture 一文中得到推廣。這個(gè)模型主要用于 SSR (也包括 SSG) 應(yīng)用,我們知道,在傳統(tǒng)的 SSR 應(yīng)用中,服務(wù)端會(huì)給瀏覽器響應(yīng)完整的 HTML 內(nèi)容,并在 HTML 中注入一段完整的 JS 腳本用于完成事件的綁定,也就是完成 hydration (注水) 的過(guò)程。當(dāng)注水的過(guò)程完成之后,頁(yè)面也才能真正地能夠進(jìn)行交互。
          當(dāng)一個(gè)頁(yè)面中只有部分的組件交互,那么對(duì)于這些可交互的組件,我們可以執(zhí)行 hydration 過(guò)程,因?yàn)榻M件之間是互相獨(dú)立的。
          而對(duì)于靜態(tài)組件,即不可交互的組件,我們可以讓其不參與 hydration 過(guò)程,直接復(fù)用服務(wù)端下發(fā)的 HTML 內(nèi)容。
          可交互的組件就猶如整個(gè)頁(yè)面中的孤島(Island),因此這種模式叫做 Islands 架構(gòu)。

          Islands 實(shí)現(xiàn)原理

          Astro

          https://astro.build/

          在 Astro 中,默認(rèn)所有的組件都是靜態(tài)組件,比如:

          // index.astro
          import MyReactComponent from '../components/MyReactComponent.jsx';
          ---
          <MyReactComponent />
          這種寫(xiě)法不會(huì)在瀏覽器添加任何的 JS 代碼。但有時(shí)我們需要在組件中綁定一些交互事件,那么這時(shí)就需要激活孤島組件了,在使用組件時(shí)加上client:load指令即可:
          // index.astro
          ---
          import MyReactComponent from '../components/MyReactComponent.jsx';
          ---
          <MyReactComponent client:load />

          Astro 除了支持本身 Astro 語(yǔ)法之外,也支持 Vue、React 等框架,可以通過(guò)插件的方式來(lái)導(dǎo)入。在構(gòu)建的時(shí)候,Astro 只會(huì)打包并注入 Islands 組件的代碼,并且在瀏覽器渲染,分別調(diào)用不同框架(Vue、React)的渲染函數(shù)完成各個(gè) Islands 組件的 hydrate 過(guò)程。

          Astro 是典型的 MPA 方案,不支持引入 SPA 的路由和狀態(tài)管理。

          Fresh

          Fresh 是一個(gè)基于 Preact 和 Deno 的全棧框架,同時(shí)也主打 Islands 架構(gòu)。它約定項(xiàng)目中的 islands 目錄專(zhuān)門(mén)存放 island 組件:
          .
          ├── README.md
          ├── components
          │   └── Button.tsx
          ├── deno.json
          ├── dev.ts
          ├── fresh.gen.ts
          ├── import_map.json
          ├── islands                 // Islands 組件目錄
          │   └── Counter.tsx
          ├── main.ts
          ├── routes
          │   ├── [name].tsx
          │   ├── api
          │   │   └── joke.ts
          │   └── index.tsx
          ├── static
          │   ├── favicon.ico
          │   └── logo.svg
          └── utils
              └── twind.ts
          Fresh 在渲染層核心主要做了以下的事情:
          • 通過(guò)掃描 islands 目錄記錄項(xiàng)目中聲明的所有 Islands 組件。
          • 攔截 Preact 中 vnode 的創(chuàng)建邏輯,目的是為了匹配之前記錄的 Island 組件,如果能匹配上,則記錄 Island 組件的 props 信息,并將組件用  的注釋標(biāo)簽來(lái)包裹,id 值為 Island 的 id,數(shù)字為該 Island 的 props 在全局 props 列表中的位置,方便 hydrate 的時(shí)候能夠找到對(duì)應(yīng)組件的 props。
          • 調(diào)用 Preact 的 renderToString 方法將組件渲染為 HTML 字符串。
          • 向 HTML 中注入客戶端 hydrate 的邏輯。
          • 拼接完整的 HTML,返回給前端。
          值得注意的是客戶端 hydrate 方法的實(shí)現(xiàn),傳統(tǒng)的 SSR 一般都是直接對(duì)根節(jié)點(diǎn)調(diào)用 hydrate,而在 Islands 架構(gòu)中,F(xiàn)resh 對(duì)每個(gè) Island 進(jìn)行獨(dú)立渲染。
          更多細(xì)節(jié)可以參考篇文章:深入解讀 Fresh

          實(shí)踐分享

          筆者基于 Islands 架構(gòu)開(kāi)發(fā)了一個(gè)文檔站方案 island.js。
          • 倉(cāng)庫(kù): https://github.com/sanyuan0704/island.js
          大體定位是支持 Mdx 的類(lèi) VitePress 方案,目前也實(shí)現(xiàn)了 Islands + MPA 架構(gòu),接下來(lái)給大家分享一下這個(gè)方案是如何來(lái)實(shí)現(xiàn) Islands 架構(gòu)的。

          使用 Island 組件

          與 Astro 類(lèi)似,Island.js 里面默認(rèn)采用 MPA 且 0 JS 的方案,如果存在存在交互的組件,在使用的時(shí)候傳入一個(gè)__island 標(biāo)志即可,比如:
          import { Aside } from '../components/Aside';

          export function Layout({
            return <Aside __island />;
          }
          這樣在生產(chǎn)環(huán)境打包的過(guò)程中自動(dòng)識(shí)別出 Islands 組件并打包,在 hydrate 的時(shí)候各自執(zhí)行 hydration。

          內(nèi)部實(shí)現(xiàn)

          總體流程如下:
          1. SSR Runtime
          指組件 renderToString 的過(guò)程,我們需要在這個(gè)運(yùn)行時(shí)過(guò)程中搜集到所有的 Islands 組件。主要的實(shí)現(xiàn)思路是攔截組件創(chuàng)建的邏輯,在 React 中可以通過(guò)攔截 React.createElement 實(shí)現(xiàn)或者 jsx-runtime 來(lái)完成,Island.js 里面實(shí)現(xiàn)了后者,通過(guò)自定義 jsx-runtime 來(lái)攔截 SSR 運(yùn)行時(shí):
          // island-jsx-runtime.js
          import * as jsxRuntime from 'react/jsx-runtime';

          export const data = {
            // 存放 islands 組件的 props
            islandProps: [],
            // 存放 islands 組件的文件路徑
            islandToPathMap: {}
          };

          const originJsx = jsxRuntime.jsx;
          const originJsxs = jsxRuntime.jsxs;

          const internalJsx = (jsx, type, props, ...args) => {
            if (props && props.__island) {
              data.islandProps.push(props || {});
              const id = type.name;
              // __island 的 prop 將在 SSR 構(gòu)建階段轉(zhuǎn)換為 `__island: 文件路徑`
              data.islandToPathMap[id] = props.__island;
              delete props.__island;

              return jsx('div', {
                __island: `${id}:${data.islandProps.length - 1}`,
                children: jsx(type, props, ...args)
              });
            }
            return jsx(type, props, ...args);
          };

          export const jsx = (...args) => internalJsx(originJsx, ...args);

          export const jsxs = (...args) => internalJsx(originJsxs, ...args);

          export const Fragment = jsxRuntime.Fragment;
          然后在 JSX 編譯階段,指定 jsxRuntime 參數(shù)為我們自定義的路徑即可。
          2. Build Time
          Build Time 分為兩個(gè)階段: renderToString 之前、renderToString 之后。
          renderToString 之前會(huì)打兩份 bundle:
          • SSR bundle: 用于 renderToString
          • Client bundle: 客戶端 Runtime 代碼,用于激活頁(yè)面
          在  SSR bundle 生成過(guò)程中,我們會(huì)特殊處理 __island prop,它實(shí)際上是為了標(biāo)識(shí)該組件是一個(gè) Islands 組件,但我們拿不到組件的路徑信息。為了之后能夠順利打包 Islands 組件,我們需要在 SSR 構(gòu)建過(guò)程中將 __isalnd 進(jìn)行轉(zhuǎn)換,使之帶上路徑信息。比如下面有兩個(gè)組件:
          // Layout.tsx
          import { Aside } from './Aside.tsx';

          export function Layout({
            return (
              <div>
                <Aside __island a={1} />
              </div>
            )
          }

          /
          / Aside.tsx
          export function Aside() {
            return <div>內(nèi)容省略...</
          div>
          }
          可以看到 Layout 組件中通過(guò)<Aside __island /> 的方式來(lái)使用 Aside 組件,標(biāo)識(shí)其為一個(gè) Islands 組件。那么我們將會(huì)在 SSR 編譯過(guò)程中用 babel 插件改寫(xiě)這個(gè) prop,原理如下:
          <Aside __island />
          // 被轉(zhuǎn)換為
          <Aside __island="./Aside.tsx!!island!!Users/project/src/Layout.tsx" />
          這樣,在 renderToString 過(guò)程中,我們就能記錄下 Islands 組件所在的文件路徑。當(dāng) renderToString 完成之后,我們可以通過(guò)自定義的 jsx-runtime 模塊拿到如下的數(shù)據(jù):
          {
            islandProps: [ { a: 1 } ],
            islandToPathMap: {
              Aside: './Aside.tsx!!island!!Users/project/src/Layout.tsx'
            }
          }
          之后在 Build Time 會(huì)做兩件事情:
          1. 將 islandProps  的數(shù)據(jù)作為 id 為island-props 的  script 標(biāo)簽注入到  HTML 中;
          2. 根據(jù) islandToPathMap 的信息構(gòu)造虛擬模塊,打包所有的 Islands 組件。
          虛擬模塊內(nèi)容如下:
          import { Aside } from './Aside.tsx!!island!!Users/project/src/Layout.tsx';

          window.islands = {
            Aside
          };

          window.ISLAND_PROPS = JSON.parse(
            document.getElementById('island-props').textContent
          );

          將這個(gè)虛擬模塊打包后我們得到一份 Islands bundle,將這個(gè) bundle 注入到 HTML 中以完成 Islands 組件的注冊(cè)。

          ?? 問(wèn)題: islands bundle 和 client bundle 有共同的依賴(lài) React,由于在兩次不同的打包流程中,所以 React 會(huì)打包兩份。解決方案是 external 掉 react 和 react-dom 依賴(lài),通過(guò) import map 指向全局唯一的 React 實(shí)例。

          3. Client Runtime
          在客戶端渲染階段,我們僅需要少量的腳本來(lái)激活 Islands 組件:
            import { hydrateRoot, createRoot } from 'react-dom/client';
            
            const islands = document.querySelectorAll('[__island]');
            for (let i = 0; i < islands.length; i++) {
              const island = islands[i];
              const [id, index] = island.getAttribute('__island')!.split(':');
              const Element = window.ISLANDS[id];
              hydrateRoot(
                island,
                <Element {...window.ISLAND_PROPS[index]}></Element>
              );
            }
          由此,我們便在 React 實(shí)現(xiàn)了 Islands 架構(gòu),在實(shí)際的頁(yè)面渲染過(guò)程中,瀏覽器僅需請(qǐng)求 React + 少量組件的代碼甚至是 0 js。SSG + SPA 方案和 Islands 架構(gòu)的頁(yè)面加載情況對(duì)比如下:
          SSG + SPA
          SSG + Islands architecture(MPA)

          HTTP 請(qǐng)求資源(KB)FCP (s)DCL(s)
          SPA 模式4510.480.84
          Islands 模式1410.400.52
          優(yōu)化情況減少近 60%變化不大提前近 40%

          Islands 架構(gòu)的適用性

          1. 框架無(wú)關(guān)

          Island 架構(gòu)的實(shí)現(xiàn)其實(shí)是可以做到框架無(wú)關(guān)的。從 SSR Runtime、Build Time  到 Client Runtime,整個(gè)環(huán)節(jié)中關(guān)于 React 的部分,我們都可以替換成其它框架的實(shí)現(xiàn),這些部分包括:

          • 創(chuàng)建組件的方法

          • 組件轉(zhuǎn)換為 HTML 字符串的 renderToString 方法

          • 瀏覽器端的 hydrate 方法

          因此,不光是 React,對(duì)于 Vue、Preact、Solidjs 這些框架中都可以實(shí)現(xiàn) Islands 架構(gòu)。因此,在 Island.js 中兼容除 React 的其它框架也是原理上完全可行的。

          并且考慮到 React 的包體積問(wèn)題,后續(xù) Island.js 考慮適配其它的框架,如 Solid,體積相比 React 可以減少 90%:

          數(shù)據(jù)來(lái)源: https://dev.to/this-is-learning/javascript-framework-todomvc-size-comparison-504f

          2. VitePress 的特殊優(yōu)化
          關(guān)于是否需要支持 Vue,這里就不得不提到目前基于 Vue  框架的文檔方案 VitePress 了,Vue 官網(wǎng)現(xiàn)已接入 VitePress 方案,那基于 VitePress 是否需要做 Islands 架構(gòu)的優(yōu)化呢?
          答案是不需要。VitePress 內(nèi)部使用的是 Shell  架構(gòu),以 Vue 官網(wǎng)為例:

          VitePress 會(huì)在 hydrate 的過(guò)程中把正文的靜態(tài)部分排除,具體實(shí)現(xiàn)原理如下:

          • Vue 模板編譯階段,Vue 會(huì)對(duì)靜態(tài)虛擬 DOM 節(jié)點(diǎn)進(jìn)行優(yōu)化,輸出 createStaticVNode 的格式

          • 在 Chunk 生成階段(實(shí)現(xiàn)鏈接),把內(nèi)容部分用 __VP_STATIC_START____VP_STATIC_END__ 標(biāo)志位包裹
          • 在生成打包產(chǎn)物前,針對(duì)每個(gè)頁(yè)面打包出兩份 JS
            • 一份是包含完整內(nèi)容的 JS,把標(biāo)志位去掉即可,比如文件名為recommend.[hash].js
            • 另一份是不包含內(nèi)容的 JS,把標(biāo)志位及其里面的內(nèi)容刪掉,文件名為recommend.[hash].lean.js
          由于 VitePress 采用的是 SSG + SPA 模式,其會(huì)根據(jù)是否為首屏來(lái)分發(fā)不同的 JS:
          • 首屏使用  .lean.js,不包含正文部分的 JS,實(shí)現(xiàn) Partial Client Bundle + Partial Hydration,跟 Islands 架構(gòu)一樣的效果
          • 二次頁(yè)面跳轉(zhuǎn)使用完整的 JS,因?yàn)樽?SPA 路由跳轉(zhuǎn),需要拿到完整的頁(yè)面內(nèi)容,用 JS 渲染出來(lái)。
          你可能會(huì)問(wèn)了,在 .lean.js 里面,組件的代碼都被改了,難道 Vue 在 hydrate 不會(huì)發(fā)現(xiàn)內(nèi)容和服務(wù)端渲染的 HTML 對(duì)應(yīng)不上進(jìn)而報(bào)錯(cuò)嗎?答案是不會(huì),我們可以看看 Vue 里面 createStaticVNode 的實(shí)現(xiàn):
          注意第二個(gè)傳參,里面會(huì)記錄靜態(tài)節(jié)點(diǎn)的數(shù)量,在 hydrate 的過(guò)程中對(duì)靜態(tài)節(jié)點(diǎn)會(huì)特殊處理,直接檢查 staticCount即節(jié)點(diǎn)數(shù)量而不是內(nèi)容,那么對(duì)于如下的 VNode 節(jié)點(diǎn)來(lái)講 hydrate 仍然是可以成功的:
          // recommend.[hash].lean.js
          const html = ` A <span>foo</span> B`
          const { vnode, container } = mountWithHydration(html, () =>
          // 保證第二個(gè)參數(shù)正確即可
            createStaticVNode(``3)
          )

          總之, VitePress 利用 Vue 的編譯時(shí)優(yōu)化以及內(nèi)部定制的 Hydrate 方案足以解決傳統(tǒng) SSG 的全量 hydration 問(wèn)題,采用 Islands 架構(gòu)意義并不大。

          那進(jìn)一步講,像 Vue 這種 Shell 優(yōu)化方案對(duì)于包含編譯時(shí)的前端框架是否通用?這里我們可以先大概總結(jié)出 Shell 方案需要滿足的條件:

          • 模板編譯階段,將靜態(tài)節(jié)點(diǎn)進(jìn)行特殊標(biāo)記

          • 運(yùn)行時(shí),支持 hydrate 跳過(guò)對(duì)靜態(tài)節(jié)點(diǎn)的內(nèi)容檢查

          基于上面這兩點(diǎn),其他的代表性編譯時(shí)框架如Solid、Svelte 很難實(shí)現(xiàn) Vue 的 Shell 架構(gòu)(沒(méi)法標(biāo)記靜態(tài)節(jié)點(diǎn)),因此 Shell 方案可以理解為在 Vue 框架下的一個(gè)特殊優(yōu)化了。對(duì)于 Vue  外的其它框架方案,仍然可以采用 Islands 進(jìn)行特定場(chǎng)景的優(yōu)化。


          往期推薦


          5 種瀑布流場(chǎng)景的實(shí)現(xiàn)原理解析
          純前端實(shí)現(xiàn)「羊了個(gè)羊」小游戲
          你的圖片加載,一點(diǎn)都不酷炫!不信 You Look Look

          最后


          • 歡迎加我微信,拉你進(jìn)技術(shù)群,長(zhǎng)期交流學(xué)習(xí)...

          • 歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個(gè)專(zhuān)業(yè)的技術(shù)人...

          點(diǎn)個(gè)在看支持我吧

          瀏覽 49
          點(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>
                  a视频在线 | 亚洲免费观看在线观看 | 奇米影视四色中文字幕 | 97在线青娱乐 | 婷婷黄色网址导航 |