微前端框架qiankun源碼剖析之下篇
引言
承接上文 微前端框架qiankun源碼剖析之上篇
注意: 受篇幅限制,本文中所粘貼的代碼都是經(jīng)過作者刪減梳理后的,只為講述qiankun框架原理而展示,并非完整源碼。如果需要閱讀相關源碼可以自行打開文中鏈接。
四、沙箱隔離
在基于single-spa開發(fā)的微前端應用中,子應用開發(fā)者需要特別注意的是:
要謹慎修改和使用全局變量上的屬性(如window、document等),以免造成依賴該屬性的自身應用或其它子應用運行時出現(xiàn)錯誤;
要謹慎控制CSS規(guī)則的生效范圍,避免覆蓋污染其它子應用的樣式;
但這樣的低級人為保證機制是無法在大規(guī)模的團隊開發(fā)過程中對應用的獨立性起到完善保護的,而qiankun框架給我們提供的最便利和有用的功能就是其基于配置的自動化沙箱隔離機制了。有了框架層面的子應用隔離支持,用戶無論是在編寫JS代碼還是修改CSS樣式時都不必再擔心代碼對于全局環(huán)境的污染問題了。沙箱機制一方面提升了微應用框架運行的穩(wěn)定性和獨立性,另一方面也降低了微前端開發(fā)者的心智負擔,讓其只需專注于自己的子應用代碼開發(fā)之中。
4.1 JS隔離
在JS隔離方面,qiankun為開發(fā)者提供了三種不同模式的沙箱機制,分別適用于不同的場景之中。
1. Snapshot沙箱
該沙箱主要用于不支持Proxy對象的低版本瀏覽器之中,不能由用戶手動指定該模式,qiankun會自動檢測瀏覽器的支持情況并降級到Snapshot沙箱實現(xiàn)。由于這種實現(xiàn)方式在子應用運行過程中實際上修改了全局變量,因此不能用于多例模式之中(同時存在多個已掛載的子應用)。
該沙箱實現(xiàn)方式非常簡潔,下面我們給出其簡化后的實現(xiàn)(源碼地址github.com/umijs/qiank…
// 基于 diff 方式實現(xiàn)的沙箱,用于不支持 Proxy 的低版本瀏覽器
export default class SnapshotSandbox implements SandBox {
private windowSnapshot!: Window;
private modifyPropsMap: Record<any, any> = {};
constructor() {}
active() {
// 記錄當前快照
this.windowSnapshot = {} as Window;
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
// 恢復之前的變更
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});
}
inactive() {
this.modifyPropsMap = {};
iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 記錄變更,恢復環(huán)境
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
});
}
}
沙箱內(nèi)部存在兩個對象變量windowSnapshot和modifyPropsMap ,分別用來存儲子應用掛載前原始window對象上的全部屬性以及子應卸載時被其修改過的window對象上的相關屬性。
Snapshot沙箱會在子應用mount前將modifyPropsMap中存儲的屬性重新賦值給window以恢復該子應用之前執(zhí)行時的全局變量上下文,并在子應用unmount后將windowSnapshot中存儲的屬性重新賦值給window以恢復該子應用運行前的全局變量上下文,從而使得兩個不同子應用的window相互獨立,達到JS隔離的目的。
2. Legacy沙箱
當用戶手動配置sandbox.loose: true時該沙箱被啟用。Legacy沙箱同樣會對window造成污染,但是其性能比要比snapshot沙箱好,因為該沙箱不用遍歷window對象。同樣legacy沙箱也只適用于單例模式之中。
下面一起來看一下其簡化后的大致實現(xiàn)方式(源碼地址github.com/umijs/qiank…
/**
* 基于 Proxy 實現(xiàn)的沙箱
* TODO: 為了兼容性 singular 模式下依舊使用該沙箱,等新沙箱穩(wěn)定之后再切換
*/
export default class LegacySandbox implements SandBox {
/** 沙箱代理的全局變量 */
proxy: WindowProxy;
/** 沙箱期間新增的全局變量 */
private addedPropsMapInSandbox = new Map<PropertyKey, any>();
/** 沙箱期間更新的全局變量 */
private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();
/** 持續(xù)記錄更新的(新增和修改的)全局變量的 map,用于在任意時刻做 snapshot */
private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();
constructor() {
const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;
const rawWindow = window;
const fakeWindow = Object.create(null) as Window;
const setTrap = (p: PropertyKey, value: any, originalValue: any) => {
if (!rawWindow.hasOwnProperty(p)) {
// 當前 window 對象不存在該屬性,將其記錄在新增變量之中
addedPropsMapInSandbox.set(p, value);
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
// 如果當前 window 對象存在該屬性,且 record map 中未記錄過,則記錄該屬性初始值
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
// 無論何種修改都記錄在currentUpdatedPropsValueMap中
currentUpdatedPropsValueMap.set(p, value);
// 必須重新設置 window 對象保證下次 get 時能拿到已更新的數(shù)據(jù)
(rawWindow as any)[p] = value;
};
const proxy = new Proxy(fakeWindow, {
set: (_: Window, p: PropertyKey, value: any): boolean => {
const originalValue = (rawWindow as any)[p];
return setTrap(p, value, originalValue, true);
},
get(_: Window, p: PropertyKey): any {
// avoid who using window.window or window.self to escape the sandbox environment to touch the really window or use window.top to check if an iframe context
if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
return proxy;
}
const value = (rawWindow as any)[p];
return value;
},
});
this.proxy = proxy
}
active() {
// 激活時將子應用之前的所有改變重新賦予window,恢復其運行時上下文
this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
}
inactive() {
// 卸載時將window上修改的值復原,新添加的值刪除
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true));
}
private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
if (value === undefined && toDelete) {
delete (this.globalContext as any)[prop];
} else {
(this.globalContext as any)[prop] = value;
}
}
}
Legacy沙箱為一個空對象fakewindow使用proxy代理攔截了其全部的set/get等操作,并在loader中用其替換了window。當用戶試圖修改window屬性時,fakewindow上代理的set操作生效捕獲了相關修改,其分別將新增的屬性和修改前的值存入addedPropsMapInSandbox和modifiedPropsOriginalValueMapInSandbox這兩個Map之中,此外還將所有修改記錄在了currentUpdatedPropsValueMap之中,并改變了window對象。
這樣當子應用掛載前,legacy沙箱會將currentUpdatedPropsValueMap之中記錄的子應用相關修改重新賦予window,恢復其運行時上下文。當子應用卸載后,legacy沙箱會遍歷addedPropsMapInSandbox和modifiedPropsOriginalValueMapInSandbox這兩個Map并將window上的相關值恢復到子應用運行之前的狀態(tài)。最終達到了子應用間JS隔離的目的。
3. Proxy沙箱
Proxy沙箱是qiankun框架中默認使用的沙箱模式(也可以通過配置sandbox.loose: false來開啟),只有該模式真正做到了對window的無污染隔離(子應用完全不能修改全局變量),因此可以被應用在單/多例模式之中。
Proxy沙箱的原理也非常簡單,它將window上的所有屬性遍歷拷貝生成一個新的fakeWindow對象,緊接著使用proxy代理這個fakeWindow,用戶對window操作全部被攔截下來,只作用于在這個fakeWindow之上(源碼地址github.com/umijs/qiank…
// 便利window拷貝創(chuàng)建初始代理對象
function createFakeWindow(globalContext: Window) {
const fakeWindow = {} as FakeWindow;
Object.getOwnPropertyNames(globalContext)
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
});
return { fakeWindow };
}
/**
* 基于 Proxy 實現(xiàn)的沙箱
*/
export default class ProxySandbox implements SandBox {
// 標志該沙箱是否被啟用
sandboxRunning = true;
constructor() {
const { fakeWindow } = createFakeWindow(window);
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
if(this.sandboxRunning){
// 修改代理對象的值
target[p] = value;
return true;
}
}
get: (target: FakeWindow, p: PropertyKey): any => {
// avoid who using window.window or window.self to escape the sandbox environment to touch the really window
if (p === 'window' || p === 'self' || p === 'globalThis') {
return proxy;
}
// 獲取代理對象的值
const value = target[p];
return value;
},
})
}
active() {
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}
inactive() {
this.sandboxRunning = false;
}
}
4.2 CSS隔離
對于CSS隔離的方式,在默認情況下由于切換子應用時,其相關的CSS內(nèi)外連屬性會被卸載掉,所以可以確保單實例場景子應用之間的樣式隔離,但是這種方式無法確保主應用跟子應用、或者多實例場景的子應用樣式隔離。不過,qiankun也提供了兩種可配置生效的內(nèi)置方式供使用者選擇。
1. ShadowDOM
當用戶配置sandbox.strictStyleIsolation: true時,ShadowDOM樣式沙箱會被開啟。在這種模式下 qiankun 會為每個微應用的容器包裹上一個 shadow dom 節(jié)點,從而確保微應用的樣式不會對全局造成影響。(源碼地址github.com/umijs/qiank…
// 在子應用的DOM樹最外層進行一次包裹
function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appInstanceId: string,
): HTMLElement {
// 包裹節(jié)點
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
// 子應用最外層節(jié)點
const appElement = containerElement.firstChild as HTMLElement;
// 當開啟了ShadowDOM沙箱時
if (strictStyleIsolation) {
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
// 判斷瀏覽器兼容的創(chuàng)建ShadowDOM的方式,并使用該方式創(chuàng)建ShadowDOM根節(jié)點
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// createShadowRoot was proposed in initial spec, which has then been deprecated
shadow = (appElement as any).createShadowRoot();
}
// 將子應用內(nèi)容掛在ShadowDOM根節(jié)點下
shadow.innerHTML = innerHTML;
}
// 。。。。。。
return appElement;
}
這種方式雖然看起來清晰簡單,還巧妙利用了瀏覽器對于ShadowDOM的CSS隔離特性,但是由于ShadowDOM的隔離比較嚴格,所以這并不是一種無腦使用的方案。例如:如果子應用內(nèi)存在一個彈出時會掛在document根元素的彈窗,那么該彈窗的樣式是否會受到ShadowDOM的影響而失效?所以開啟該沙箱的用戶需要明白自己在做什么,且可能需要對子應用內(nèi)部代碼做出一定的調(diào)整。
2. Scoped CSS
因為ShadowDOM存在著上述的一些問題,qiankun貼心的為用戶提供了另一種更加無腦簡便的樣式隔離方式,那就是Scoped CSS。通過配置sandbox.experimentalStyleIsolation: true,Scoped樣式沙箱會被開啟。
在這種模式下,qiankun會遍歷子應用中所有的CSS選擇器,通過對選擇器前綴添加一個固定的帶有該子應用標識的屬性選擇器的方式來限制其生效范圍,從而避免子應用間、主應用與子應用的樣式相互污染。(源碼地址github.com/umijs/qiank…
export const QiankunCSSRewriteAttr = 'data-qiankun';
// 在子應用的DOM樹最外層進行一次包裹
function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appInstanceId: string,
): HTMLElement {
// 包裹節(jié)點
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
// 子應用最外層節(jié)點
const appElement = containerElement.firstChild as HTMLElement;
// 。。。。。。
// 當開啟了Scoped CSS沙箱時
if (scopedCSS) {
// 為外層節(jié)點添加qiankun自定義屬性,其值設定為子應用id標識
const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
if (!attr) {
appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
}
// 獲取子應用中全部樣式并進行處理
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
}
return appElement;
}
qiankun首先對子應用最外層的包裹節(jié)點(一般為div節(jié)點)添加一個屬性名為data-qiankun,值為appInstanceId的屬性。接著遍歷處理子應用中的所有樣式(源碼地址github.com/umijs/qiank…
export const process = (
appWrapper: HTMLElement,
stylesheetElement: HTMLStyleElement | HTMLLinkElement,
appName: string,
): void => {
// lazy singleton pattern
if (!processor) {
processor = new ScopedCSS();
}
// ?。。∽⒁?,對于link標簽引入的外聯(lián)樣式不支持。qiankun在初期解析使用的import-html-entry在解析html模版時會將所有外聯(lián)樣式拉取并轉換為style標簽包裹的內(nèi)聯(lián)樣式,所以這里不再處理link的外聯(lián)樣式。
if (stylesheetElement.tagName === 'LINK') {
console.warn('Feature: sandbox.experimentalStyleIsolation is not support for link element yet.');
}
const mountDOM = appWrapper;
if (!mountDOM) {
return;
}
// 獲取包裹元素標簽
const tag = (mountDOM.tagName || '').toLowerCase();
if (tag && stylesheetElement.tagName === 'STYLE') {
// 生成屬性選擇器前綴,準備將其添加在選擇器前(如div[data-qiankun=app1])
const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
processor.process(stylesheetElement, prefix);
}
};
// 。。。。。。
process(styleNode: HTMLStyleElement, prefix: string = '') {
if (styleNode.textContent !== '') {
// 獲取相關css規(guī)則rules
const textNode = document.createTextNode(styleNode.textContent || '');
this.swapNode.appendChild(textNode);
const sheet = this.swapNode.sheet as any; // type is missing
const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
// 重寫這些CSS規(guī)則,將前綴添加進去
const css = this.rewrite(rules, prefix);
// 用重寫后的CSS規(guī)則覆蓋之前的規(guī)則
styleNode.textContent = css;
// 標志符,代表該節(jié)點已經(jīng)處理過
(styleNode as any)[ScopedCSS.ModifiedTag] = true;
return;
}
// 監(jiān)聽節(jié)點變化
const mutator = new MutationObserver((mutations) => {
for (let i = 0; i < mutations.length; i += 1) {
const mutation = mutations[i];
// 忽略已經(jīng)處理過的節(jié)點
if (ScopedCSS.ModifiedTag in styleNode) {
return;
}
// 如果新增了未處理過的子節(jié)點(代表了用戶新注入了一些屬性),那么會再次重寫所有的CSS規(guī)則以確保新增的CSS不會污染子應用外部
if (mutation.type === 'childList') {
const sheet = styleNode.sheet as any;
const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
const css = this.rewrite(rules, prefix);
styleNode.textContent = css;
(styleNode as any)[ScopedCSS.ModifiedTag] = true;
}
}
});
// 注冊監(jiān)聽
mutator.observe(styleNode, { childList: true });
}
// 具體CSS規(guī)則重寫方式
private rewrite(rules: CSSRule[], prefix: string = '') {
// 。。。。。。
// 這里省略其實現(xiàn)方式,整體實現(xiàn)思路簡單但步驟很繁瑣,主要就是對字符串的正則判斷和替換修改。
// 1. 對于根選擇器(html/body/:root等),直接將其替換為prefix
// 2. 對于其它選擇器,將prefix放在最前面( selector1 selector2, selector3 =》 prefix selector1 selector2,prefix selector3)
}
可以看到,qiankun通過為子應用的外層包裹元素注入屬性并將子應用全部樣式的作用范圍都限制在該包裹元素下(通過添加指定的屬性選擇器作為前綴)實現(xiàn)了scoped樣式沙箱隔離。需要注意的是,如果用戶在運行時對內(nèi)聯(lián)樣式進行修改,qiankun是可以偵測到并幫助用戶限制其作用范圍,但如果用戶在運行時引入了新的外聯(lián)樣式或者自行創(chuàng)建了新的內(nèi)聯(lián)標簽,那么qiankun并不會做出反應,相關的CSS規(guī)則還是可能會污染全局樣式。
五、通信方式
對于微前端來說,除了應用間的隔離外,應用間的通信也是非常重要的部分。這里,single-spa提供了從主應用向子應用傳遞customProps的方式實現(xiàn)了最基礎的參數(shù)傳遞。但是真實的開發(fā)場景需要的信息傳遞是非常復雜的,靜態(tài)的預設參數(shù)傳遞只能起到很小的作用,我們還需要一種更加強大的通信機制來幫助我們開發(fā)應用。
這里,qiankun在框架內(nèi)部預先設計實現(xiàn)了完善的發(fā)布訂閱模式,降低了開發(fā)者的上手門檻。我們首先來看一下qiankun中的通信是如何進行的。
// ------------------主應用------------------
import { initGlobalState, MicroAppStateActions } from 'qiankun';
// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);
// 在當前應用監(jiān)聽全局狀態(tài),有變更觸發(fā) callback
actions.onGlobalStateChange((state, prev) => {
// state: 變更后的狀態(tài); prev 變更前的狀態(tài)
console.log(state, prev);
});
// 按一級屬性設置全局狀態(tài),微應用中只能修改已存在的一級屬性
actions.setGlobalState(state);
// 移除當前應用的狀態(tài)監(jiān)聽,微應用 umount 時會默認調(diào)用
actions.offGlobalStateChange();
// ------------------子應用------------------
// 從生命周期 mount 中獲取通信方法,使用方式和 master 一致
export function mount(props) {
props.onGlobalStateChange((state, prev) => {
// state: 變更后的狀態(tài); prev 變更前的狀態(tài)
console.log(state, prev);
});
props.setGlobalState(state);
}
接下來,讓我們一起來看一下它是如何實現(xiàn)的。(源碼地址github.com/umijs/qiank…
import { cloneDeep } from 'lodash';
import type { OnGlobalStateChangeCallback, MicroAppStateActions } from './interfaces';
// 全局狀態(tài)
let globalState: Record<string, any> = {};
// 緩存相關的訂閱者
const deps: Record<string, OnGlobalStateChangeCallback> = {};
// 觸發(fā)全局監(jiān)聽
function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) {
Object.keys(deps).forEach((id: string) => {
if (deps[id] instanceof Function) {
// 依次通知訂閱者
deps[id](cloneDeep(state), cloneDeep(prevState));
}
});
}
// 初始化
export function initGlobalState(state: Record<string, any> = {}) {
if (state === globalState) {
console.warn('[qiankun] state has not changed!');
} else {
const prevGlobalState = cloneDeep(globalState);
globalState = cloneDeep(state);
emitGlobal(globalState, prevGlobalState);
}
// 返回相關方法,形成閉包存儲相關狀態(tài)
return getMicroAppStateActions(`global-${+new Date()}`, true);
}
export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions {
return {
/**
* onGlobalStateChange 全局依賴監(jiān)聽
*
* 收集 setState 時所需要觸發(fā)的依賴
*
* 限制條件:每個子應用只有一個激活狀態(tài)的全局監(jiān)聽,新監(jiān)聽覆蓋舊監(jiān)聽,若只是監(jiān)聽部分屬性,請使用 onGlobalStateChange
*
* 這么設計是為了減少全局監(jiān)聽濫用導致的內(nèi)存爆炸
*
* 依賴數(shù)據(jù)結構為:
* {
* {id}: callback
* }
*
* @param callback
* @param fireImmediately 是否立即執(zhí)行callback
*/
onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
if (!(callback instanceof Function)) {
console.error('[qiankun] callback must be function!');
return;
}
if (deps[id]) {
console.warn(`[qiankun] '${id}' global listener already exists before this, new listener will overwrite it.`);
}
/ 注冊訂閱
deps[id] = callback;
if (fireImmediately) {
const cloneState = cloneDeep(globalState);
callback(cloneState, cloneState);
}
},
/**
* setGlobalState 更新 store 數(shù)據(jù)
*
* 1. 對輸入 state 的第一層屬性做校驗,只有初始化時聲明過的第一層(bucket)屬性才會被更改
* 2. 修改 store 并觸發(fā)全局監(jiān)聽
*
* @param state
*/
setGlobalState(state: Record<string, any> = {}) {
if (state === globalState) {
console.warn('[qiankun] state has not changed!');
return false;
}
const changeKeys: string[] = [];
const prevGlobalState = cloneDeep(globalState);
globalState = cloneDeep(
Object.keys(state).reduce((_globalState, changeKey) => {
if (isMaster || _globalState.hasOwnProperty(changeKey)) {
changeKeys.push(changeKey);
return Object.assign(_globalState, { [changeKey]: state[changeKey] });
}
console.warn(`[qiankun] '${changeKey}' not declared when init state!`);
return _globalState;
}, globalState),
);
if (changeKeys.length === 0) {
console.warn('[qiankun] state has not changed!');
return false;
}
// 觸發(fā)全局監(jiān)聽
emitGlobal(globalState, prevGlobalState);
return true;
},
// 注銷該應用下的依賴
offGlobalStateChange() {
delete deps[id];
return true;
},
};
}
可以看到在initGlobalState函數(shù)的執(zhí)行中完成了一個發(fā)布訂閱模式的創(chuàng)建工作,并返回了相關的訂閱/發(fā)布/注銷方法。接著qiankun將這些返回的方法通過生命周期函數(shù)mount傳遞給子應用,這樣子應用就能夠拿到并使用全局狀態(tài)了,從而應用間的通信就得以實現(xiàn)了。此外offGlobalStateChange會在子應用unmount時自動調(diào)用以解除該子應用的訂閱,避免內(nèi)存泄露。(第三節(jié)子應用加載中的代碼已經(jīng)提到,源碼參見github.com/umijs/qiank…
六、結語
qiankun在single-spa的基礎上進行了二次封裝,分別從子應用加載方式、應用間沙箱隔離、應用間通信這三個方面著手,通過自己的方式降低了用戶的使用門檻,簡便了微前端項目的開發(fā)改造成本,從而成為目前為止最為流行的微前端框架。
| 優(yōu)化點 | single-spa | qiankun |
|---|---|---|
| 子應用加載方式 | 用戶自行編碼配置子應用加載方式 | 用戶只需配置子應用入口URL |
| 應用間沙箱隔離 | 無隔離機制 | 內(nèi)置了三種JS沙箱和兩種CSS沙箱 |
| 應用間通信 | 主應用通過customProps向子應用傳遞靜態(tài)參數(shù) | 內(nèi)置了一整套基于發(fā)布訂閱的通信模式 |
本文通過對于qiankun源碼的粗略解讀,希望讀者可以獲取到自己所需的知識,得到些許的進步。編碼的路程漫長且艱辛,諸位共同努力!
更多關于微前端框架qiankun剖析的資料請關注腳本之家其它相關文章!
相關文章
Intl對象DateTimeFormat?ListFormat?RelativeTimeFormat使用講解
這篇文章主要為大家介紹了Intl對象DateTimeFormat?ListFormat?RelativeTimeFormat使用講解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-06-06
微信小程序 wxapp內(nèi)容組件 progress詳細介紹
這篇文章主要介紹了微信小程序 wxapp內(nèi)容組件 progress詳細介紹的相關資料,需要的朋友可以參考下2016-10-10

