自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

我們聊聊從頭學服務器組件:在導航間保留狀態(tài)

開發(fā) 前端
本文,我們?yōu)榉斩嗽黾恿朔祷?JSX 數(shù)據(jù)的支持,并使用 React 在客戶端進行消費,實現(xiàn)基于 JSX 結構的頁面初始化和頁面局部更新。出于安全考慮,React 要求 JSX 節(jié)點中需要包含一個 $$typeof: Symbol.for("react.element")? 屬性。為此,我們在序列化和解析的時候,對 $$typeof 做了特殊的轉換處理。

存在的問題

到目前為止,訪問每個頁面,服務器返回的都是一個 HTML 字符串:

async function sendHTML(res, jsx) {
  const html = await renderJSXToHTML(jsx);
  res.setHeader("Content-Type", "text/html");
  res.end(html);
}

這對瀏覽器首次加載是非常友好的——瀏覽器有針對 HTML 渲染做針對性優(yōu)化,會盡可能快地展示——但對于頁面導航就不太理想了。我們希望頁面支持布局刷新,即只更新 "發(fā)生變化的部分",頁面其他部分不受影響,也讓交互變得流暢了。

為了說明這個問題,我們向 BlogLayout 組件的 <nav> 中添加一個 <input />:

<nav>
  <a href="/">Home</a>
  <hr />
  <input />
  <hr />
</nav>

請注意,每次瀏覽博客時,輸入框里的內容(或叫“狀態(tài)”)都會消失:

圖片圖片

這對一個簡單的博客來說可能沒有問題,但如果要構建具有復雜交互的應用程序,這種行為在某些時候就會讓人頭疼。因此,你需要讓用戶在應用程序中隨意瀏覽時,不會丟失頁面狀態(tài)。

接下來,我們將分 3 步驟來解決這個問題:

  1. 添加一些客戶端 JS 邏輯攔截導航,修改默認行為(這樣我們就可以手動重新獲取內容,而無需重新加載整個頁面)
  2. 修改服務端代碼,支持在隨后的導航中需要提供返回 JSX 響應(而不是 HTML)的支持
  3. 修改客戶端代碼,在不破壞 DOM 的情況下執(zhí)行 JSX 更新(提示:這部分將使用 React)

局部更新支持

步驟 1:攔截導航

我們需要一些客戶端邏輯,因此增加一個 client.js 文件。先修改服務端代碼(即 server.js),增加靜態(tài)資源的路由請求支持(即"/client.js")。

createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);
    // 增加靜態(tài)資源 "/client.js" 的路由支持
    if (url.pathname === "/client.js") {
      await sendScript(res, "./client.js"); // 返回服務端本地的 client.js 文件
    } else {
      await sendHTML(res, <Router url={url} />);
    }
  } catch (err) {
    // ...
  }
}).listen(8080)

async function sendScript(res, filename) {
  const content = await readFile(filename, "utf8");
  res.setHeader("Content-Type", "text/javascript");
  res.end(content);
}

另外,在 sendHTML() 中為響應的 HTML  文本增加  <script> 標簽,引用 client.js 文件。

async function sendHTML(res, jsx) {
  let html = await renderJSXToHTML(jsx);
  html += `<script type="module" src="/client.js"></script>`;
  res.setHeader("Content-Type", "text/html");
  res.end(html);
}

現(xiàn)在說回 client.js。在這個文件中,我們會覆蓋網(wǎng)站內導航的默認行為(譯注:此處指<a> 標簽鏈接能力),改調我們自己的 navigate() 函數(shù):

async function navigate(pathname) {
  // TODO
}

window.addEventListener("click", (e) => {
  // 只處理 <a> 標簽的點擊事件
  if (e.target.tagName !== "A") {
    return;
  }
  // Ignore "open in a new tab".
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
    return;
  }
  // Ignore external URLs.
  const href = e.target.getAttribute("href");
  if (!href.startsWith("/")) {
    return;
  }
  // 這里是核心代碼。首先,阻止 <a> 標簽默認的跳轉行為
  e.preventDefault();
  // 然后,將跳轉地址推入瀏覽歷史,瀏覽器地址欄的地址也同步更新
  window.history.pushState(null, null, href);
  // 最后,執(zhí)行自定義的導航行為
  navigate(href);
}, true);

window.addEventListener("popstate", () => {
  // When the user presses Back/Forward, call our custom logic too.
  navigate(window.location.pathname);
});

navigate() 函數(shù)就是編寫自定義導航行為的地方。在 navigate() 函數(shù)中,我們使用 fetch API 請求下一個路由的 HTML 響應,并使用這個響應更新頁面:

let currentPathname = window.location.pathname;

async function navigate(pathname) {
  currentPathname = pathname;
  // 獲取當前導航路由下的 HTML 數(shù)據(jù)
  const response = await fetch(pathname);
  const html = await response.text();

  if (pathname === currentPathname) {
    // 獲取 HTML 響應數(shù)據(jù) <body> 標簽里的內容
    const bodyStartIndex = html.indexOf("<body>") + "<body>".length;
    const bodyEndIndex = html.lastIndexOf("</body>");
    const bodyHTML = html.slice(bodyStartIndex, bodyEndIndex);

    // 將拿到的 HTML 數(shù)據(jù)替換掉當前 <body> 里的內容
    document.body.innerHTML = bodyHTML;
  }
}

點擊這里的 線上 demo[7] 查看效果。

可以看到,在頁面不刷新的情況下,頁面內容被替換了,表示我們已經(jīng)成功覆蓋了瀏覽器默認導航行為。不過,由于目前是針對 <body> 元素的整體覆蓋,所以<input> 里的數(shù)據(jù)仍然會丟失。接下來,我們會修改服務器部分,增加返回 JSX 響應的支持。

步驟 2:增加返回 JSX 響應的支持

還記得我們之前使用 JSX 組成的對象樹結構吧?

{
  $$typeof: Symbol.for("react.element"),
  type: 'html',
  props: {
    children: [
      {
        $$typeof: Symbol.for("react.element"),
        type: 'head',
        props: {
          // ... And so on ...

接下來,我們將為服務器增加以 ?jsx 結尾的請求支持。?jsx 請求返回的是一個 JSX 樹結構,而不是 HTML,這更容易讓客戶端確定哪些部分發(fā)生了變化,并只在必要時更新 DOM,這將直接解決 <input> 狀態(tài)在每次導航時丟失的問題,但這并不是我們這樣做的唯一原因。接下來,你將看到我們如何將新信息(不僅僅是 HTML)從服務器傳遞到客戶端。

首先修改服務器代碼,支持請求中攜帶 ?jsx 參數(shù)時調用一個新的 sendJSX() 函數(shù):

createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);
    if (url.pathname === "/client.js") {
      // ...
    } else if (url.searchParams.has("jsx")) {
      url.searchParams.delete("jsx"); // 確保傳遞給 <Router> 的 url 是干凈的
      await sendJSX(res, <Router url={url} />); // 返回 JSX 數(shù)據(jù)
    } else {
      await sendHTML(res, <Router url={url} />);
    }
    // ...

在 sendJSX() 中,我們使用 JSON.stringify(jsx) 把的 JSX 樹轉換成一個 JSON 字符串,并通過網(wǎng)絡返回:

async function sendJSX(res, jsx) {
  const jsxString = JSON.stringify(jsx, null, 2); // Indent with two spaces.
  res.setHeader("Content-Type", "application/json");
  res.end(jsxString);
}

要注意的是,我們并不是發(fā)送 JSX 語法本身(如 "<Foo />" ),只是將 JSX 生成的樹結構轉換成 JSON 字符串。當然,真正的 RSC 實現(xiàn)使用的是并不是 JSON 格式,這里只是為了方便理解。真正的實現(xiàn)方式我們將在下一個系列進行探討。

修改客戶端代碼,看看目前返回的數(shù)據(jù):

async function navigate(pathname) {
  currentPathname = pathname;
  const response = await fetch(pathname + "?jsx");
  const jsonString = await response.text();
  if (pathname === currentPathname) {
    alert(jsonString);
  }
}

點擊這里[8],加載首頁,然后點擊一個鏈接會看到一個提示,類似下面這樣:

{
  "key": null,
  "ref": null,
  "props": {
    "url": "http://localhost:3000/hello-world"
  },
  // ...
}

這并不是我們想要的結構,我們希望看到類似 <html>...</html> 的 JSX 樹。那是哪里出問題了呢?

我們分析一下,現(xiàn)在的 JSX 看起來是這樣的:

<Router url="http://localhost:3000/hello-world" />
// {
//   $$typeof: Symbol.for('react.element'),
//   type: Router,
//   props: { url: "http://localhost:3000/hello-world" } },
//    ...
// }

這種結構發(fā)送給客戶端還“為時過早”,我們不知道 Router 具體的 JSX 內容,因為 Router 只存在服務器上。我們需要在服務端調用 Router 組件,才能得到需要發(fā)送給客戶端的 JSX 內容。

如果,我們使用 { url: "http://localhost:3000/hello-world" } } 作為 prop 調用 Router 函數(shù),會得到這樣一段 JSX:

<BlogLayout>
  <BlogIndexPage />
</BlogLayout>

一樣的,把這個結果發(fā)送回客戶端還為時過早,因為我們不知道 BlogLayout 的內容,而且它只存在于服務器上,因此我們還必須調用 BlogLayout,得到最終傳遞給客戶端的 JSX。

先想一下我們的結果——調用結束后,我們希望得到一個不引用任何服務器代碼的 JSX 樹。類似:

<html>
  <head>...</head>
  <body>
    <nav>
      <a href="/">Home</a>
      <hr />
    </nav>
    <main>
    <section>
      <h1>Welcome to my blog</h1>
      <div>
        ...
      </div>
    </main>
    <footer>
      <hr />
      <p>
        <i>
          (c) Jae Doe 2003
        </i>
      </p>
    </footer>
  </body>
</html>

這個結果可以直接傳遞給 JSON.stringify() 并發(fā)送給客戶端。

首先,編寫一個 renderJSXToClientJSX() 函數(shù)。接收一段 JSX 作為參數(shù),"解析 "服務器專有部分(通過調用相應組件獲得),直到只剩下客戶端可以理解的 JSX。

結構上看,這個函數(shù)類似于 renderJSXToHTML(),但它遞歸遍歷返回的是對象,而不是 HTML:

async function renderJSXToClientJSX(jsx) {
  if (
    typeof jsx === "string" ||
    typeof jsx === "number" ||
    typeof jsx === "boolean" ||
    jsx == null
  ) {
    // Don't need to do anything special with these types.
    return jsx;
  } else if (Array.isArray(jsx)) {
    // Process each item in an array.
    return Promise.all(jsx.map((child) => renderJSXToClientJSX(child)));
  } else if (jsx != null && typeof jsx === "object") {
    if (jsx.$$typeof === Symbol.for("react.element")) {
      if (typeof jsx.type === "string") {
        // This is a component like <div />.
        // Go over its props to make sure they can be turned into JSON.
        return {
          ...jsx,
          props: await renderJSXToClientJSX(jsx.props),
        };
      } else if (typeof jsx.type === "function") {
        // This is a custom React component (like <Footer />).
        // Call its function, and repeat the procedure for the JSX it returns.
        const Component = jsx.type;
        const props = jsx.props;
        const returnedJsx = await Component(props);
        return renderJSXToClientJSX(returnedJsx);
      } else throw new Error("Not implemented.");
    } else {
      // This is an arbitrary object (for example, props, or something inside of them).
      // Go over every value inside, and process it too in case there's some JSX in it.
      return Object.fromEntries(
        await Promise.all(
          Object.entries(jsx).map(async ([propName, value]) => [
            propName,
            await renderJSXToClientJSX(value),
          ])
        )
      );
    }
  } else throw new Error("Not implemented");
}

接下來編輯 sendJSX(),先將類似 <Router /> 的 JSX 轉換為 "客戶端 JSX",然后再對其字符串化:

async function sendJSX(res, jsx) {
  const clientJSX = await renderJSXToClientJSX(jsx);
  const clientJSXString = JSON.stringify(clientJSX, null, 2); // Indent with two spaces
  res.setHeader("Content-Type", "application/json");
  res.end(clientJSXString);
}

點擊這里的 線上 demo[9] 查看效果。

現(xiàn)在點擊鏈接就會顯示一個與 HTML 很類似的樹狀結構提示,這表示我們可以對它進行差異化處理了。

注意:目前我們的目標是能最低要求的工作起來,但在實現(xiàn)過程中還有很多不盡如人意的地方。例如,格式本身非常冗長和重復,真正的 RSC 使用的是更簡潔的格式。就像前面生成 HTML 一樣,整個響應被 await 是很糟糕的體驗。理想情況下,我們希望將 JSX 分塊進行流式傳輸,并在客戶端進行拼接。同樣,共享布局的部分內容(如 <html> 和 <nav> )沒有發(fā)生變化,現(xiàn)在也要重新發(fā)送這些內容。生產就緒的 RSC 實現(xiàn)并不存在這些缺陷,不過我們暫時接受這些缺陷,讓代碼看起來更容易理解。

步驟 3:在客戶端執(zhí)行 JSX 更新

嚴格來說,我們不必使用 React 來擴展 JSX。

到目前為止,我們的 JSX 節(jié)點只包含瀏覽器內置組件(例如:<nav>、<footer>)。為了實現(xiàn)對服務端返回的 JSX 數(shù)據(jù),執(zhí)行差異化處理和更新,我們會直接使用 React。

我們?yōu)?React 提供的 JSX 數(shù)據(jù),會幫助它知道要創(chuàng)建的 DOM 節(jié)點信息,也方便將來安全地進行更改。同樣,React 處理過程·中,也會遍歷 DOM,查看每個 DOM 節(jié)點對應的 JSX 信息。這樣,React 就能將事件處理程序附加到 DOM 節(jié)點,讓節(jié)點具備交互性,我們把這個過程叫水合(hydration)。

傳統(tǒng)上,要將服務器渲染的標記水合,需要調用 hydrateRoot(),需要為 React 提供一個掛載 DOM 節(jié)點,還有服務器上創(chuàng)建的初始 JSX。類似這樣:

// Traditionally, you would hydrate like this
hydrateRoot(document, <App />);

問題是我們在客戶端根本沒有像 <App /> 這樣的根組件!從客戶端的角度來看,目前我們的整個應用程序就是一大塊 JSX,沒有任何 React 組件。然而,React 真正需要的只是與初始 HTML 相對應的 JSX 樹。這個樹類似 <html>...</html> 這樣的:

import { hydrateRoot } from 'react-dom/client';

const root = hydrateRoot(document, getInitialClientJSX());

function getInitialClientJSX() {
  // TODO: return the <html>...</html> client JSX tree mathching the initial HTML
}

這個過程會非??欤驗榭蛻舳朔祷氐?JSX 樹中沒有任何自定義組件。React 將會以近乎瞬時的速度遍歷 DOM 樹和 JSX 樹,并構建稍后更新樹時所需要的一些內部數(shù)據(jù)結構。

然后,在每次用戶導航時,我們獲取下一頁的 JSX,并通過 root.render[10] 更新 DOM:

async function navigate(pathname) {
  currentPathname = pathname;
  const clientJSX = await fetchClientJSX(pathname);
  if (pathname === currentPathname) {
    root.render(clientJSX);
  }
}

async function fetchClientJSX(pathname) {
  // TODO: fetch and return the <html>...</html> client JSX tree for the next route
}

補充完 getInitialClientJSX() 和 fetchClientJS() 的內容后。React 就會按照我們預期的方式,創(chuàng)建并托管我們的項目,同時也不會丟失狀態(tài)。

現(xiàn)在,就讓我們來看看如何實現(xiàn)這兩個功能。

從服務器獲取 JSX

我們先從實現(xiàn) fetchClientJSX() 開始,因為它更容易實現(xiàn)。

首先,讓我們回顧一下 ?jsx 服務器端的實現(xiàn)。

async function sendJSX(res, jsx) {
  const clientJSX = await renderJSXToClientJSX(jsx);
  const clientJSXString = JSON.stringify(clientJSX);
  res.setHeader("Content-Type", "application/json");
  res.end(clientJSXString);
}

我們在客戶端向服務端發(fā)送 JSX 請求,然后將響應代入 JSON.parse(),再將其轉換為 JSX:

async function fetchClientJSX(pathname) {
  const response = await fetch(pathname + "?jsx");
  const clientJSXString = await response.text();
  const clientJSX = JSON.parse(clientJSXString);
  return clientJSX;
}

這樣實現(xiàn)之后[11],點擊鏈接渲染 JSX 時就會報錯:

Objects are not valid as a React child (found: object with keys {type, key, ref, props, _owner, _store}).

原因是我們傳遞給 JSON.stringify() 的對象是這樣的:

{
  $$typeof: Symbol.for("react.element"),
    type: 'html',
    props: {
    // ...

但是查看客戶端 JSON.parse() 結果,$$typeof 屬性在傳輸過程中丟失了:

{
  type: 'html',
  props: {
    // ...

對 React 來說,如果 JSX 節(jié)點中沒有 $$typeof: Symbol.for("react.element")  屬性,就不會被看作是有效節(jié)點。這是一種有意為之的安全機制——默認情況下,React 不會隨意將一個任意 JSON 對象視為 JSX 標記。因此,就利用了 Symbol 值無法在 JSON 序列化過程中存在的特性,為 JSX 增加了一個Symbol.for('react.element') 屬性。

不過,我們確實在服務器上創(chuàng)建了這些 JSX 節(jié)點,而且確實希望在客戶端上呈現(xiàn)它們。因此,我們需要調整邏輯,以 "保留" $$typeof: Symbol.for("react.element") 屬性。

幸運的是,這個問題并不難解決。JSON.stringify() 接受一個替換函數(shù)[12],可以讓我們自定義 JSON 的生成行為。在服務器上,我們將用 "$RE" 這樣的特殊字符串替換 Symbol.for('react.element'):

async function sendJSX(res, jsx) {
  // ...
  const clientJSXString = JSON.stringify(clientJSX, stringifyJSX); // Notice the second argument
  // ...
}

function stringifyJSX(key, value) {
  if (value === Symbol.for("react.element")) {
    // We can't pass a symbol, so pass our magic string instead.
    return "$RE"; // Could be arbitrary. I picked RE for React Element.
  } else if (typeof value === "string" && value.startsWith("$")) {
    // To avoid clashes, prepend an extra $ to any string already starting with $.
    return "$" + value;
  } else {
    return value;
  }
}

在客戶端,我們將向 JSON.parse() 傳遞一個 reviver 函數(shù),將 "$RE" 替換為 Symbol.for('react.element') :

async function fetchClientJSX(pathname) {
  // ...
  const clientJSX = JSON.parse(clientJSXString, parseJSX); // Notice the second argument
  // ...
}

function parseJSX(key, value) {
  if (value === "$RE") {
    // This is our special marker we added on the server.
    // Restore the Symbol to tell React that this is valid JSX.
    return Symbol.for("react.element");
  } else if (typeof value === "string" && value.startsWith("$$")) {
    // This is a string starting with $. Remove the extra $ added by the server.
    return value.slice(1);
  } else {
    return value;
  }
}

點擊這里的 線上 demo[13] 查看效果。

現(xiàn)在,在頁面間進行導航,不過更新全部是以 JSX 形式獲取并在客戶端應用的!

如果你在輸入框中輸入內容,然后點擊鏈接,會發(fā)現(xiàn) <input> 狀態(tài)在所有導航中都得到了保留,除第一個導航除外。這是因為我們還沒有告訴 React 頁面的初始 JSX 是什么,所以它無法正確關聯(lián)服務器返回的 HTML。

將初始 JSX 內聯(lián)到 HTML 中

還有這段代碼:

const root = hydrateRoot(document, getInitialClientJSX());

function getInitialClientJSX() {
  return null; // TODO
}

我們需要將根節(jié)點與初始客戶端 JSX 相結合,但客戶端上的 JSX 怎么來?

為了解決這個問題,我們可以把初始 JSX 字符串作為客戶端的全局變量:

const root = hydrateRoot(document, getInitialClientJSX());

function getInitialClientJSX() {
  const clientJSX = JSON.parse(window.__INITIAL_CLIENT_JSX_STRING__, reviveJSX);
  return clientJSX;
}

在服務器上,我們修改 sendHTML() 函數(shù),以便將應用程序渲染為客戶端 JSX,并將其內聯(lián)到 HTML 末尾:

async function sendHTML(res, jsx) {
  let html = await renderJSXToHTML(jsx);

  // Serialize the JSX payload after the HTML to avoid blocking paint:
  const clientJSX = await renderJSXToClientJSX(jsx);
  const clientJSXString = JSON.stringify(clientJSX, stringifyJSX);
  html += `<script>window.__INITIAL_CLIENT_JSX_STRING__ = `;
  html += JSON.stringify(clientJSXString).replace(/</g, "\\u003c");
  html += `</script>`;
  // ...

最后,我們需要對文本節(jié)點生成 HTML 的方式進行一些小調整[14],以便 React 可以將它們水合。

點擊這里的 線上 demo[15] 查看效果。

現(xiàn)在你可以輸入一些內容,其數(shù)據(jù)不會在導航間丟失:

圖片圖片

這就是我們最初設定的目標!當然,保存這個輸入框數(shù)據(jù)并不是重點,重要的是,我們的應用程序實現(xiàn)了“就地 ”刷新和導航,而不必丟失任何狀態(tài)。

注意:雖然真正的 RSC 實現(xiàn)確實會在 HTML payload 對 JSX 進行編碼,但仍存在一些重要的差異。生產就緒的 RSC 設置會在生成 JSX 塊時發(fā)送它們,而不是在最后發(fā)送單個大 Blob 數(shù)據(jù)。當 React 加載時,水合作用可以立即開始——React 使用已經(jīng)可用的 JSX 塊遍歷樹,而不是非要等到它們全部到達。 RSC 還允許將某些組件標記為客戶端組件,這時候它們仍然會 SSR 到 HTML 中,但它們的代碼會包含在捆綁包中。對于客戶端組件,只有其 props 的 JSON 被序列化。將來,React 可能會添加額外的機制來刪除 HTML 和嵌入的 payload之間的重復內容。

總結

本文,我們?yōu)榉斩嗽黾恿朔祷?JSX 數(shù)據(jù)的支持,并使用 React 在客戶端進行消費,實現(xiàn)基于 JSX 結構的頁面初始化和頁面局部更新。出于安全考慮,React 要求 JSX 節(jié)點中需要包含一個 $$typeof: Symbol.for("react.element") 屬性。為此,我們在序列化和解析的時候,對 $$typeof 做了特殊的轉換處理。

至此,現(xiàn)在我們的代碼實際上可以工作了,而且架構已經(jīng)很接近真正的 RSC 了(雖然沒有引入流式傳輸這樣的復雜機制)。在下一篇,也就是本系列的終篇,我們將對現(xiàn)在的代碼做一些清理工作,修復一些缺陷,并為下一波功能做好準備。

責任編輯:武曉燕 來源: 寫代碼的寶哥
相關推薦

2018-02-07 10:24:01

Nginx服務器架構

2021-06-02 07:43:42

服務器阿里云配置

2019-02-20 13:57:48

游戲服務器框架

2018-08-31 10:10:06

2019-09-10 09:40:11

游戲服務器框架

2018-10-15 09:39:12

服務器開發(fā)語言

2011-01-12 11:54:46

ARM

2012-02-13 23:46:22

keepalived高可用

2022-02-22 11:57:32

BOAWeb服務器

2010-04-28 11:22:46

2020-08-07 14:28:04

裸金屬服務器云服務

2018-01-19 10:30:48

HTTP服務器代碼

2019-06-13 17:15:30

監(jiān)控Linux服務器

2020-06-07 11:54:34

Linux服務器命令

2010-05-18 15:25:07

IIS服務器

2010-05-19 10:31:07

IIS服務器

2011-03-24 10:59:10

Nagios監(jiān)控mysql

2009-08-12 13:18:46

IBMX86服務器

2021-05-26 11:30:34

戴爾

2019-01-15 10:54:03

高性能ServerReactor
點贊
收藏

51CTO技術棧公眾號