React18 之 Suspense

来源:https://www.cnblogs.com/dtux/p/18028903
-Advertisement-
Play Games

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。 本文作者:佳嵐 Suspense Suspense 組件我們並不陌生,中文名可以理解為暫停or懸停 , 在 React16 中我們通常在路由懶載入中配合 Lazy 組件 ...


我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。

本文作者:佳嵐

Suspense

Suspense 組件我們並不陌生,中文名可以理解為暫停or懸停  , 在 React16 中我們通常在路由懶載入中配合 Lazy 組件一起使用 ,當然這也是官方早起版本推薦的唯一用法。

那它暫停了什麼? 進行非同步網路請求,然後再拿到請求後的數據進行渲染是很常見的需求,但這不可避免的需要先渲染一次沒有數據的頁面,數據返回後再去重新渲染。so , 我們想要暫停的就是第一次的無數據渲染。

通常我們在沒有使用Suspense 時一般採用下麵這種寫法, 通過一個isLoading狀態來顯示載入中或數據。這樣代碼是不會有任何問題,但我們需要手動去維護一個isLoading 狀態的值。

const [data, isLoading] = fetchData("/api");
if (isLoading) {
  return <Spinner />;
}
return <MyComponent data={data} />;

當我們使用Suspense 後,使用方法會變為如下, 我們只需將進行非同步數據獲取的組件進行包裹,並將載入中組件通過fallback傳入

return (
  <Suspense fallback={<Spinner />}>
    <MyComponent />
  </Suspense>
);

那 React 是如何知道該顯示MyComponent還是Spinner的?

答案就在於MyComponent內部進行fetch遠程數據時做了一些手腳。

export const App = () => {
  return (
    <div>
      <Suspense fallback={<Spining />}>
        <MyComponent />
      </Suspense>
    </div>
  );
};

function Spining() {
  return <p>loading...</p>;
}

let data = null;

function MyComponent() {
  if (!data) {
    throw new Promise((resolve) => {
      setTimeout(() => {
        data = 'kunkun';
        resolve(true);
      }, 2000);
    });
  }
  return (
    <p>
      My Component, data is {data}
    </p>
  );
}

Suspense是根據捕獲子組件內的異常來實現決定展示哪個組件的。這有點類似於ErrorBoundary ,不過ErrorBoundary是捕獲 Error 時就展示回退組件,而Suspense 捕獲到的 Error 需要是一個Promise對象(並非必須是 Promise 類型,thenable 的都可以)。

我們知道 Promise 有三個狀態,pendingfullfilledrejected ,當我們進行遠程數據獲取時,會創建一個Promise,我們需要直接將這個Promise 作為Error進行拋出,由 Suspense 進行捕獲,捕獲後對該thenable對象的then方法進行回調註冊thenable.then(retry) , 而 retry 方法就會開始一個調度任務進行更新,後面會詳細講。
file

知道了大致原理,這時還需要對我們的fetcher進行一層包裹才能實際運用。

// MyComponent.tsx
const getList = wrapPromise(fetcher('http://api/getList'));

export function MyComponent() {
  const data = getList.read();

  return (
    <ul>
      {data?.map((item) => (
        <li>{item.name}</li>
      ))}
    </ul>
  );
}

function fetcher(url) {
  return new Promise((resove, reject) => {
    setTimeout(() => {
      resove([{ name: 'This is Item1' }, { name: 'This is Item2' }]);
    }, 1000);
  });
}

// Promise包裹函數,用來滿足Suspense的要求,在初始化時預設就會throw出去
function wrapPromise(promise) {
  let status = 'pending';
  let response;

  const suspend = promise.then(
    (res) => {
      status = 'success';
      response = res;
    },
    (err) => {
      status = 'error';
      response = err;
    }
  );
  const read = () => {
    switch (status) {
      case 'pending':
        throw suspend;
      default:
        return response;
    }
  };

  return { read };

從上述代碼我們可以註意到,通過const data = getList.read() 這種同步的方式我們就能拿到數據了。 註意: 上面這種寫法並非一種範式,目前官方也沒有給出推薦的寫法
為了與Suspense配合,則我們的請求可能會變得很不優雅 ,官方推薦是直接讓我們使用第三方框架提供的能力使用Suspense請求數據,如 useSWR
下麵時useSWR的示例,簡明瞭很多,並且對於Profile組件,數據獲取的寫法可以看成是同步的了。

import { Suspense } from 'react'
import useSWR from 'swr'
 
function Profile () {
  const { data } = useSWR('/api/user', fetcher, { suspense: true })
  return <div>hello, {data.name}</div>
}
 
function App () {
  return (
    <Suspense fallback={<div>loading...</div>}>
      <Profile/>
    </Suspense>
  )
}

Suspense的另一種用法就是與懶載入lazy組件配合使用,在完成載入前展示Loading

<Suspense fallback={<GlobalLoading />}>
   {lazy(() => import('xxx/xxx.tsx'))}
</Suspense>

由此得出,通過lazy返回的組件也應該包裹一層類似如上的 Promise,我們看看 lazy 內部是如何實現的。
其中ctor就是我們傳入的() => import('xxx/xxx.tsx'), 執行lazy也只是幫我們封裝了層數據結構。ReactLazy.js

export function lazy<T>(
  ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {
  const payload: Payload<T> = {
    // We use these fields to store the result.
    _status: Uninitialized,
    _result: ctor,
  };
  const lazyType: LazyComponent<T, Payload<T>> = {
    $$typeof: REACT_LAZY_TYPE,
    _payload: payload,
    _init: lazyInitializer,
  };
  return lazyType;
}

React 會在Reconciler過程中去實際執行,在協調的render階段beginWork中可以看到對lazy單獨處理的邏輯。 ReactFiberBeginWork.js

function mountLazyComponent(
  _current,
  workInProgress,
  elementType,
  renderLanes,
) {
  const props = workInProgress.pendingProps;
  const lazyComponent: LazyComponentType<any, any> = elementType;
  const payload = lazyComponent._payload;
  const init = lazyComponent._init;
	// 在此處初始化lazy
  let Component = init(payload);
	// 下略
}

那我們再來看看init幹了啥,也就是封裝前的lazyInitializer方法,整體跟我們之前實現的 fetch 封裝是一樣的。
ReactLazy.js

function lazyInitializer<T>(payload: Payload<T>): T {
  if (payload._status === Uninitialized) {
    const ctor = payload._result;
	// 這時候開始進行遠程模塊的導入
    const thenable = ctor();
    thenable.then(
      moduleObject => {
        if (payload._status === Pending || payload._status === Uninitialized) {
          // Transition to the next state.
          const resolved: ResolvedPayload<T> = (payload: any);
          resolved._status = Resolved;
          resolved._result = moduleObject;
        }
      },
      error => {
        if (payload._status === Pending || payload._status === Uninitialized) {
          // Transition to the next state.
          const rejected: RejectedPayload = (payload: any);
          rejected._status = Rejected;
          rejected._result = error;
        }
      },
    );
  }
  if (payload._status === Resolved) {
    const moduleObject = payload._result;
    }
    return moduleObject.default;
  } else {
    // 第一次執行肯定會先拋出異常
    throw payload._result;
  }
}

Suspense 底層是如何實現的?

其底層細節非常之多,在開始之前,我們先回顧下 React 的大致架構

Scheduler: 用於調度任務,我們每次setState可以看成是往其中塞入一個Task,由Scheduler內部的優先順序策略進行判斷何時調度運行該Task

Reconciler: 協調器,進行 diff 演算法,構建 fiber 樹

Renderer: 渲染器,將 fiber 渲染成 dom 節點

Fiber 樹的結構, 在 reconciler 階段,採用深度優先的方式進行遍歷,往下遞即調用beginWork的過程,往上回溯即調用ComplteWork的過程
file
我們先直接進入Reconciler 中分析下Suspensefiber節點是如何被創建的
beginWork

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
	switch (workInProgress.tag) {
		case HostText:
      return updateHostText(current, workInProgress);
    case SuspenseComponent:
      return updateSuspenseComponent(current, workInProgress, renderLanes);
		// 省略其他類型
	}
}
  • beginWork中會根據**不同的組件類型**執行不同的創建方法, 而Suspense 對應的會進入到updateSuspenseComponent

updateSuspenseComponent

function updateSuspenseComponent(current, workInProgress, renderLanes) {
  const nextProps = workInProgress.pendingProps;

  let showFallback = false;
  // 標識該Suspense是否已經捕獲過子組件的異常了
  const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;

  if (
    didSuspend
  ) {
    showFallback = true;
    workInProgress.flags &= ~DidCapture;
  } 

  // 第一次組件載入
  if (current === null) {

    const nextPrimaryChildren = nextProps.children;
    const nextFallbackChildren = nextProps.fallback;
   
    // 第一次預設不展示fallback,因為要先走到children後才會產生異常
    if (showFallback) {
      const fallbackFragment = mountSuspenseFallbackChildren(
        workInProgress,
        nextPrimaryChildren,
        nextFallbackChildren,
        renderLanes,
      );
      const primaryChildFragment: Fiber = (workInProgress.child: any);
      primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
        renderLanes,
      );

      return fallbackFragment;
    } 
     else {
      return mountSuspensePrimaryChildren(
        workInProgress,
        nextPrimaryChildren,
        renderLanes,
      );
    }
  } else {
    // 如果是更新,操作差不多,此處略
  }
}
  • 第一次updateSuspenseComponent 時 ,我們會把mountSuspensePrimaryChildren 的結果作為下一個需要創建的fiber , 因為需要先去觸發異常。
  • 實際上mountSuspensePrimaryChildren  會為我們的PrimaryChildren 在包上一層OffscreenFiber
function mountSuspensePrimaryChildren(
  workInProgress,
  primaryChildren,
  renderLanes,
) {
  const mode = workInProgress.mode;
  const primaryChildProps: OffscreenProps = {
    mode: 'visible',
    children: primaryChildren,
  };
  const primaryChildFragment = mountWorkInProgressOffscreenFiber(
    primaryChildProps,
    mode,
    renderLanes,
  );
  primaryChildFragment.return = workInProgress;
  workInProgress.child = primaryChildFragment;
  return primaryChildFragment;
}

什麼是OffscreenFiber/Component  ?
通過其需要的 mode 參數值,我們可以大膽的猜測,應該是一個能控制是否顯示子組件的組件,如果hidden,則會通過 CSS 樣式隱藏子元素。
file
在這之後的 Fiber 樹結構
file
當我們向下執行到MyComponent 時,由於拋出了錯誤,當前的reconciler階段會被暫停
讓我們再回到 Reconciler 階段的起始點可以看到有Catch語句。renderRootConcurrent

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
 // 省略..
  do {
    try {
      workLoopConcurrent();
      break;
    } catch (thrownValue) {
      handleError(root, thrownValue);
    }
  } while (true);
 // 省略..
}

performConcurrentWorkOnRoot(root, didTimeout) {
	// 省略..
	let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    : renderRootSync(root, lanes);
  // 省略..
}

我們再看看錯誤處理函數handleError中做了些什麼  handleError

function handleError(root, thrownValue): void {
	// 這時的workInProgress指向MyComponent
  let erroredWork = workInProgress;
  try {
    throwException(
      root,
      erroredWork.return,
      erroredWork,
      thrownValue,
      workInProgressRootRenderLanes,
    );
    completeUnitOfWork(erroredWork);
}

function throwException(root: FiberRoot, returnFiber: Fiber, sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes) 
{
  // 給MyComponent打上未完成標識
  sourceFiber.flags |= Incomplete;

  if (
    value !== null &&
    typeof value === 'object' &&
    typeof value.then === 'function'
  ) {
    // wakeable就是我們拋出的Promise
    const wakeable: Wakeable = (value: any);

    // 向上找到第一個Suspense邊界
    const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
    if (suspenseBoundary !== null) {
      // 打上標識
      suspenseBoundary.flags &= ~ForceClientRender;
      suspenseBoundary.flags |= ShouldCapture;
      // 註冊監聽器
			attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
			return;
  }
}

主要做了三件事

  • 給拋出錯誤的組件打上Incomplete標識
  • 如果捕獲的錯誤是 thenable 類型,則認定為是 Suspense 的子組件,向上找到最接近的一個Suspense 邊界,並打上ShouldCapture 標識
  • 執行attachRetryListener 對 Promise 錯誤監聽,當狀態改變後開啟一個調度任務重新渲染 Suspense

在錯誤處理的事情做完後,就不應該再往下遞了,開始調用completeUnitOfWork往上歸, 這時由於我們給 MyComponent 組件打上了Incomplete 標識,這個標識表示由於異常等原因渲染被擱置,那我們是不是就要開始往上找能夠處理這個異常的組件?

我們再看看completeUnitOfWork 幹了啥

function completeUnitOfWork(unitOfWork: Fiber): void {
 // 大致邏輯
  let completedWork = unitOfWork;
  if ((completedWork.flags & Incomplete) !== NoFlags) {
      const next = unwindWork(current, completedWork, subtreeRenderLanes);
			if (next) {
					workInProgress = next;
					return
			}
			// 給父節點打上Incomplete標記
			if (returnFiber !== null) {
		      returnFiber.flags |= Incomplete;
		      returnFiber.subtreeFlags = NoFlags;
		      returnFiber.deletions = null;
			}
	}
}

可以看到最終打上Incomplete 標識的組件都會進入unwindWork流程 , 並一直將祖先節點打上Incomplete 標識,直到unwindWork 中找到一個能處理異常的邊界組件,也就ClassComponent, SuspenseComponent , 會去掉ShouldCapture標識,加上DidCapture標識

這時,對於Suspense來說需要的DidCapture已經拿到了,下麵就是重新從Suspense 開始走一遍beginWork流程

再次回到 Suspense 組件, 這時由於有了DidCapture 標識,則展示fallback
對於fallback組件的fiber節點是通過mountSuspenseFallbackChildren 生成的

function mountSuspenseFallbackChildren(
  workInProgress,
  primaryChildren,
  fallbackChildren,
  renderLanes,
) {
  const primaryChildProps: OffscreenProps = {
    mode: 'hidden',
    children: primaryChildren,
  };

  let primaryChildFragment = mountWorkInProgressOffscreenFiber(
      primaryChildProps,
      mode,
      NoLanes,
    );
  let fallbackChildFragment = createFiberFromFragment(
      fallbackChildren,
      mode,
      renderLanes,
      null,
    );

  primaryChildFragment.return = workInProgress;
  fallbackChildFragment.return = workInProgress;
  primaryChildFragment.sibling = fallbackChildFragment;
  workInProgress.child = primaryChildFragment;
  return fallbackChildFragment;
}

它主要做了三件事

  • PrimaryChildOffscreen組件通過css隱藏
  • fallback組件又包了層Fragment 返回
  • fallbackChild 作為sibling鏈接至PrimaryChild

file
到這時渲染 fallback 的 fiber 樹已經基本構建完了,之後進入commit階段從根節點rootFiber開始深度遍歷該fiber樹 進行 render。

等待一段時間後,primary組件數據返回,我們之前在handleError中添加的監聽器attachRetryListener 被觸發,開始新的一輪任務調度。註:源碼中調度回調實際在 Commit 階段才添加的。

這時由於Suspense 節點已經存在,則走的是updateSuspensePrimaryChildren 中的邏輯,與之前首次載入時 monutSuspensePrimaryChildren不同的是多了刪除的操作, 在 commit 階段時則會刪除fallback 組件, 展示primary組件。updateSuspensePrimaryChildren

if (currentFallbackChildFragment !== null) {
    // Delete the fallback child fragment
    const deletions = workInProgress.deletions;
    if (deletions === null) {
      workInProgress.deletions = [currentFallbackChildFragment];
      workInProgress.flags |= ChildDeletion;
    } else {
      deletions.push(currentFallbackChildFragment);
    }
  }

至此,Suspense 的一生我們粗略的過完了,在源碼中對 Suspense 的處理非常多,涉及到優先順序相關的本篇都略過。
Suspense 中使用了Offscreen組件來渲染子組件,這個組件的特性是能根據傳入 mode 來控制子組件樣式的顯隱,這有一個好處,就是能保存組件的狀態,有些許類似於 Vue 的keep-alive 。其次,它擁有著最低的調度優先順序,比空閑時優先順序還要低,這也意味著當 mode 切換時,它會被任何其他調度任務插隊打斷掉。
file

useTransition

useTransition 可以讓我們在不阻塞 UI 渲染的情況下更新狀態。useTransitionstartTransition 允許將某些更新標記為低優先順序更新。預設情況下,其他更新被視為緊急更新。React 將允許更緊急的更新(例如更新文本輸入)來中斷不太緊急的更新(例如展示搜索結果列表)。
其核心原理其實就是將startTransition 內調用的狀態變更方法都標識為低優先順序的lane (lane優先順序參考)去更新。

const [isPending, startTransition] = useTransition()

startTransition(() => {
	setData(xxx)
})

一個輸入框的例子

function Demo() {
  const [value, setValue] = useState();
  const [isPending, startTransition] = useTransition();

  return (
    <div>
      <h1>useTramsotopm Demo</h1>
      <input
        onChange={(e) => {
          startTransition(() => {
            setValue(e.target.value);
          });
        }}
      />
      <hr />
      {isPending ? <p>載入中。。</p> : <List value={value} />}
    </div>
  );
}

function List({ value }) {
  const items = new Array(5000).fill(1).map((_, index) => {
    return (
      <li>
        <ListItem index={index} value={value} />
      </li>
    );
  });
  return <ul>{items}</ul>;
}

function ListItem({ index, value }) {
  return (
    <div>
      <span>index: </span>
      <span>{index}</span>
      <span>value: </span>
      <span>{value}</span>
    </div>
  );
}

當我每次進行輸入時,會觸發 List 進行大量更新,但由於我使用了startTransition  對List的更新進行延後 ,所以Input輸入框不會出現明顯卡頓現象
演示地址https://stackblitz.com/edit/stackblitz-starters-kmkcjs?file=src%2Ftransition%2FList.tsx
file

由於更新被滯後了,所以我們怎麼知道當前有沒有被更新呢?
這時候第一個返回參數isPending 就是用來告訴我們當前是否還在等待中。
但我們可以看到,input組件目前是非受控組件 ,如果改為受控組件 ,即使使用了startTransition 一樣會出現卡頓,因為 input 響應輸入事件進行狀態更新應該是要同步的。
所以這時候下麵介紹的useDeferredValue 作用就來了。

useDeferredValue

useDeferredValue 可讓您推遲更新部分 UI, 它與useTransition 做的事差不多,不過useTransition 是在狀態更新層,推遲狀態更新來實現非阻塞,而useDeferredValue 則是在狀態已經更新後,先使用狀態更新前的值進行渲染,來延遲因狀態變化而導致的組件重新渲染。

它的基本用法

function Page() {
  const [value, setValue] = useState('');
  const deferredValue = useDeferredValue(setValue);
}

我們再用useDeferredValue 去實現上面輸入框的例子

function Demo() {
  const [value, setValue] = useState('');
  const deferredValue = useDeferredValue(value);

  return (
    <div>
      <h1>useDeferedValue Demo</h1>
      <input
        value={value}
        onChange={(e) => {
          setValue(e.target.value)
        }}
      />
      <hr />
      <List value={deferredValue} />
    </div>
  );
}

我們將input作為受控組件 ,對於會因輸入框值而造成大量渲染List,我們使用deferredValue

其變化過程如下

  1. 當輸入變化時,deferredValue 首先會是變化前的舊值進行重新渲染,由於值沒有變,所以 List 沒有重新渲染,也就沒有出現阻塞情況,這時,input 的值能夠實時響應到頁面上。
  2. 在這次舊值渲染完成後,deferredValue 變更為新的值,React 會在後臺開始對新值進行重新渲染,List 組件開始 rerender,且此次 rerender 會被標識為低優先順序渲染,能夠被中斷
  3. 如果此時又有輸入框輸入,則中斷此次後臺的重新渲染,重新走1,2的流程

我們可以列印下deferredValue  的值看下
初始情況輸入框為1,列印了兩次1
file

輸入2時,再次列印了兩次1,隨後列印了兩次2
file

參考

最後

歡迎關註【袋鼠雲數棧UED團隊】~
袋鼠雲數棧 UED 團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎 star


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • Rapid存儲引擎簡介 從GreatSQL 8.0.32-25版本開始,新增Rapid存儲引擎,該引擎使得GreatSQL能滿足聯機分析(OLAP)查詢請求。 Rapid引擎採用插件(Plugin)方式嵌入GreatSQL中,可以線上動態安裝或卸載。 Rapid引擎不會直接面對客戶端和應用程式,用戶 ...
  • 隨著雲計算技術的飛速發展,雲資料庫作為雲計算的重要組成部分,其實現架構與設計日益受到開發工程師的關註。本文將從開發工程師的角度出發,探討雲資料庫的實現架構,並提出雲資料庫設計的構想,以期為雲資料庫的發展提供參考。 ...
  • Linux下MySQL的安裝與使用 安裝前說明 查看是否安裝過MySQL 如果你是用rpm安裝, 檢查一下RPM PACKAGE: rpm -qa | grep -i mysql # -i 忽略大小寫 檢查mysql service: systemctl status mysqld.service ...
  • 大家好,我是 Java陳序員。 最近 Open AI 又火了一把,其新推出的文本生成視頻模型 —— Sora,引起了巨大的關註。 Sora 目前僅僅只是發佈預告視頻,還未開放出具體的 API. 今天,給大家推薦一個最近十分火熱的開源項目,一個支持使用 Sora 模型將文本生成視頻的 Web 客戶端。 ...
  • 前言 我們每天寫vue3項目的時候都會使用setup語法糖,但是你有沒有思考過下麵幾個問題。setup語法糖經過編譯後是什麼樣子的?為什麼在setup頂層定義的變數可以在template中可以直接使用?為什麼import一個組件後就可以直接使用,無需使用components 選項來顯式註冊組件? v ...
  • 寫在前面 本以為可以在家學習一天,結果家裡來了客人拜年,就沒學習上,有點小遺憾吧。 昨天完成從分類管理的前後端代碼複製出文檔管理的前後端代碼,遺留問題是只能選擇一級父分類。值得說的是,昨晚的遺留的問題修複了,開心。 遺留問題 點擊父文檔,彈出警告,從報錯來看那意思就是parent應該是一個對象,我卻 ...
  • 這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 一、是什麼 Server-Side Rendering 我們稱其為SSR,意為服務端渲染 指由服務側完成頁面的 HTML 結構拼接的頁面處理技術,發送到瀏覽器,然後為其綁定狀態與事件,成為完全可交互頁面的過程 先來看看Web3個階段的發展 ...
  • 在當今數字化時代,瀏覽器錄屏技術已經成為了一種強大的工具,用於記錄和分享網頁內容的視覺體驗。無論是用戶體驗測試、教育培訓、產品演示還是遠程協作,瀏覽器錄屏技術都能提供便捷、高效的解決方案。 線上錄屏 | 一個覆蓋廣泛主題工具的高效線上平臺(amd794.com) amd794.com/records ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...