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

          JSON-RPC & postMessage 談?wù)劄g覽器消息通信的封裝技巧

          共 12823字,需瀏覽 26分鐘

           ·

          2022-04-23 17:11

          術(shù)????

          楔子

          postMessage 常見于內(nèi)嵌 iframe 或是 Web Workers 中,用于跨頁面(線程) 的消息通信,在一些其他開發(fā)環(huán)境中也能看到類似的影子,如 Chrome 插件環(huán)境、Electron 環(huán)境、figma 插件等。

          最近的工作需要經(jīng)常與 iframeWeb Workers 打交道,處理頁面與內(nèi)嵌頁、主線程與 worker 通信,擼了個用于處理瀏覽器消息通信的處理的工具庫 rpc-shooter,涵蓋了瀏覽器主要的消息通信的接口支持:

          • Window
          • Worker
          • SharedWorker
          • ServiceWorker
          • MessageChannel
          • BroadcastChannel
          • MessagePort

          在此分享一些開發(fā)過程中的經(jīng)驗與技巧。

          原教旨主義

          先來看一個 iframe 父子級頁面通信的例子:

          //?parent.js
          const?childWindow?=?document.querySelector('iframe').contentWindow;
          window.addEventListener('message',?function?(event)?{
          ????const?data?=?event.data;
          ????if?(data.method?===?'do_something')?{
          ????????//?...?handle?iframe?data
          ????????childWindow.postMessage({
          ????????????method:?'re:do_something',
          ????????????data:?'some?data',
          ????????});
          ????}
          });

          //?iframe.js
          window.top.postMessage(
          ????{
          ????????method:?'do_something',
          ????????data:?'ifame?data',
          ????},
          ????'*'
          );
          window.addEventListener('message',?function?(event)?{
          ????const?data?=?event.data;
          ????if?(data.method?===?'re:do_something')?{
          ????????//?...?handle?parent?data
          ????}
          });

          使用原教旨主義的寫法可以很容寫出上述代碼,處理簡單消息通信不會有什么問題,但針對復(fù)雜場景下跨頁面(線程)通信則需要有個簡單有效機制來維護消息通信。

          聰明的你一定想到了基于統(tǒng)一消息格式配合對應(yīng)的消息處理策略來維護消息事件的方法調(diào)用,很簡單的機制,卻很好用:

          const?childWindow?=?document.querySelector('iframe').contentWindow;
          const?handlers?=?{
          ????add:?(a:?number,?b:?number)?=>?a?+?b,
          ????subtract:?(a:?number,?b:?number)?=>?a?-?b,
          };
          window.addEventListener('message',?function?(event)?{
          ????const?{?method,?args?}?=?event.data;
          ????const?result?=?handlers[method](...args);
          ????childWindow.postMessage({
          ????????method:?`re:${method}`,
          ????????args:?[result],
          ????});
          });

          使用上述的處理方式,消息通信的處理維護一份策略處理函數(shù)即可,接下來的工作也是建立在此基礎(chǔ)上的,加一點“細節(jié)”即可。

          事件封裝

          消息通信本身是事件的一種,所以不妨往事件封裝的方向靠,這時候就有很多可以借鑒的接口設(shè)計了,這里可以借鑒 socket.io 的接口設(shè)計。相對與本地事件調(diào)用,消息通信則本質(zhì)是監(jiān)聽遠程服務(wù)所發(fā)出的事件,與 socket.io 類似:

          //?client
          socket.emit('join-in',?input.value);
          //?server
          socket.on('join-in',(user)?=>?{...});

          面向接口

          對于一個工具函數(shù)(庫)的封裝設(shè)計,最好是從接口開始,接口設(shè)計可以直接決定最終工具使用形式。這也是 Typescript 帶來的開發(fā)模式轉(zhuǎn)變,面向接口的設(shè)計,可以幫助我們更好組裝模塊以達到解耦的目的。

          封裝的接口格式定義:

          interface?RPCHandler?{
          ????(...args:?any[]):?any;
          }

          interface?RPCEvent?{
          ????emit(event:?string,?...args:?any[]):?void;
          ????on(event:?string,?fn:?RPCHandler):?void;
          ????off(event:?string,?fn?:?RPCHandler):?void;
          }

          基于上述定義的接口,以 iframe 的父子通信為例做工具庫封裝:

          interface?RPCHandler?{
          ????(...args:?any[]):?any;
          }

          interface?RPCEvent?{
          ????emit(event:?string,?...args:?any[]):?void;
          ????on(event:?string,?fn:?RPCHandler):?void;
          ????off(event:?string,?fn?:?RPCHandler):?void;
          }

          interface?RPCMessageDataFormat?{
          ????event:?string;
          ????args:?any[];
          }

          interface?RPCMessageEventOptions?{
          ????currentEndpoint:?Window;
          ????targetEndpoint:?Window;
          ????targetOrigin:?string;
          }

          class?RPCMessageEvent?implements?RPCEvent?{
          ????private?_currentEndpoint:?RPCMessageEventOptions['currentEndpoint'];
          ????private?_targetEndpoint:?RPCMessageEventOptions['targetEndpoint'];
          ????private?_targetOrigin:?RPCMessageEventOptions['targetOrigin'];
          ????private?_events:?Record<string,?Array>;

          ????constructor(options:?RPCMessageEventOptions)?{
          ????????this._events?=?{};
          ????????this._currentEndpoint?=?options.currentEndpoint;
          ????????this._targetEndpoint?=?options.targetEndpoint;
          ????????this._targetOrigin?=?options.targetOrigin;
          ????????//?監(jiān)聽遠程消息事件
          ????????const?receiveMessage?=?(event:?MessageEvent)?=>?{
          ????????????const?{?data?}?=?event;
          ????????????const?eventHandlers?=?this._events[data.event]?||?[];
          ????????????if?(eventHandlers.length)?{
          ????????????????eventHandlers.forEach((handler)?=>?{
          ????????????????????handler(...(data.args?||?[]));
          ????????????????});
          ????????????????return;
          ????????????}
          ????????};
          ????????this._currentEndpoint.addEventListener(
          ????????????'message',
          ????????????receiveMessage?as?EventListenerOrEventListenerObject,
          ????????????false
          ????????);
          ????}

          ????emit(event:?string,?...args:?any[]):?void?{
          ????????const?data:?RPCMessageDataFormat?=?{
          ????????????event,
          ????????????args,
          ????????};
          ????????//?postMessage
          ????????this._targetEndpoint.postMessage(data,?this._targetOrigin);
          ????}

          ????on(event:?string,?fn:?RPCHandler):?void?{
          ????????if?(!this._events[event])?{
          ????????????this._events[event]?=?[];
          ????????}
          ????????this._events[event].push(fn);
          ????}

          ????off(event:?string,?fn?:?RPCHandler):?void?{
          ????????if?(!this._events[event])?return;
          ????????if?(!fn)?{
          ????????????this._events[event]?=?[];
          ????????????return;
          ????????}
          ????????const?handlers?=?this._events[event]?||?[];
          ????????this._events[event]?=?handlers.filter((handler)?=>?handler?!==?fn);
          ????}
          }

          經(jīng)典的事件實現(xiàn),這里不做贅述,使用方式如下:

          //?父級頁面
          const?childWindow?=?document.querySelector('iframe').contentWindow;
          const?parentEvent:?RPCEvent?=?new?RPCMessageEvent({
          ????targetEndpoint:?window,
          ????currentEndpoint:?childWindow,
          ????targetOrigin:?'*',
          });
          parentEvent.on('add',?(a,?b)?=>?a?+?b);
          parentEvent.emit('test');

          //?子級頁面
          const?childEvent:?RPCEvent?=?new?RPCMessageEvent({
          ????targetEndpoint:?window,
          ????currentEndpoint:?window.top,
          ????targetOrigin:?'',
          });
          childEvent.emit('add',?1,?2);
          childEvent.on('test',?()?=>?{});
          childEvent.on('max',?(a,?b)?=>?Math.max(a,?b));
          childEvent.off('max');

          思考一個問題,上述實現(xiàn)了父子級 window 對象的消息通信封裝,能否將其一般化支持到所有瀏覽器消息事件?

          答案是肯定的,看一眼事件的 Window 封裝初始化選項:

          interface?RPCMessageEventOptions?{
          ????currentEndpoint:?Window;
          ????targetEndpoint:?Window;
          ????targetOrigin:?string;
          }

          這里的事件接收與發(fā)送對象都是 Window,但實際上我們只是依賴了:

          • currentEndpoint 上的 message 事件
          • targetEndpoint 上的 postMessage 方法與其配置

          換言之,只要瀏覽器中的其他對象支持 message 事件與 postMessage 方法即可實現(xiàn)同樣的封裝,即滿足接口即可

          interface?RPCMessageEventOptions?{
          ????currentEndpoint:?{
          ????????addEventListenerextends?keyof?MessagePortEventMap>(
          ????????????type:?K,
          ????????????listener:?(
          ????????????????this:?RPCMessageEventOptions['currentEndpoint'],
          ????????????????ev:?MessagePortEventMap[K]
          ????????????)?=>?any,
          ????????????options?:?boolean?|?AddEventListenerOptions
          ????????):?void;
          ????};
          ????targetEndpoint:?{
          ????????postMessage(message:?any,?...args:?any[]):?void;
          ????};
          }

          瀏覽器中通信接口

          以下為目前瀏覽器主要支持消息通信的對象,其都實現(xiàn)了類似消息事件接口:

          interface?MessagePort?extends?EventTarget?{
          ????postMessage(message:?any,?transfer:?Transferable[]):?void;
          ????postMessage(message:?any,?options?:?StructuredSerializeOptions):?void;
          ????addEventListenerextends?keyof?MessagePortEventMap>(type:?K,?listener:?(this:?MessagePort,?ev:?MessagePortEventMap[K])?=>?any,?options?:?boolean?|?AddEventListenerOptions):?void;
          ????addEventListener(type:?string,?listener:?EventListenerOrEventListenerObject,?options?:?boolean?|?AddEventListenerOptions):?void;
          ????removeEventListener<K?extends?keyof?MessagePortEventMap>(type:?K,?listener:?(this:?MessagePort,?ev:?MessagePortEventMap[K])?=>?any,?options?:?boolean?|?EventListenerOptions):?void;
          ????removeEventListener(type:?string,?listener:?EventListenerOrEventListenerObject,?options?:?boolean?|?EventListenerOptions):?void;
          }
          image.png
          image.png
          image.png
          image.png

          有興趣的同學(xué)可以翻一翻 lib.dom.d.ts 接口定義,有時會比翻文檔來的清楚:

          • Window
          • Worker
          • ServiceWorker
          • BroadcastChannel
          • MessagePort

          綜上我們可以整一個終極縫合怪來適配所有接口:

          //?消息發(fā)送對象的接口定義
          interface?AbstractMessageSendEndpoint?{
          ????//?BroadcastChannel
          ????postMessage(message:?any):?void;
          ????//?Wroker?&&?ServiceWorker?&&?MessagePort
          ????postMessage(message:?any,?transfer:?Transferable[]):?void;
          ????postMessage(message:?any,?options?:?StructuredSerializeOptions):?void;
          ????//?window
          ????postMessage(message:?any,?options?:?WindowPostMessageOptions):?void;
          ????postMessage(message:?any,?targetOrigin:?string,?transfer?:?Transferable[]):?void;
          }

          //?消息接收對象的接口定義
          interface?AbstractMessageReceiveEndpoint?extends?EventTarget,?AbstractMessageSendEndpoint?{
          ????onmessage?:?((this:?AbstractMessageReceiveEndpoint,?ev:?MessageEvent)?=>?any)?|?null;
          ????onmessageerror?:?((this:?AbstractMessageReceiveEndpoint,?ev:?MessageEvent)?=>?any)?|?null;
          ????close?:?()?=>
          ?void;
          ????start?:?()?=>?void;

          ????addEventListenerextends?keyof?MessagePortEventMap>(
          ????????type:?K,
          ????????listener:?(this:?AbstractMessageReceiveEndpoint,?ev:?MessagePortEventMap[K])?=>?any,
          ????????options?:?boolean?|?AddEventListenerOptions
          ????):?void;
          ????addEventListener(
          ????????type:?string,
          ????????listener:?EventListenerOrEventListenerObject,
          ????????options?:?boolean?|?AddEventListenerOptions
          ????):?void;
          ????removeEventListenerextends?keyof?MessagePortEventMap>(
          ????????type:?K,
          ????????listener:?(this:?AbstractMessageReceiveEndpoint,?ev:?MessagePortEventMap[K])?=>?any,
          ????????options?:?boolean?|?EventListenerOptions
          ????):?void;
          ????removeEventListener(
          ????????type:?string,
          ????????listener:?EventListenerOrEventListenerObject,
          ????????options?:?boolean?|?EventListenerOptions
          ????):?void;
          }

          需要注意 postMessage 接口定義,實際使用 WindowPostMessageOptions 接口即可涵蓋目前所有的消息通信,其包含了 targetOrigintransfer 配置。

          interface?StructuredSerializeOptions?{
          ????transfer?:?Transferable[];
          }

          interface?WindowPostMessageOptions?extends?StructuredSerializeOptions?{
          ????targetOrigin?:?string;
          }

          interface?AbstractMessageSendEndpoint?{
          ????postMessage(message:?any,?options?:?WindowPostMessageOptions):?void;
          }

          最終的事件初始化選項接口如下,新增了一個 config 配置項用于給 postMessage 傳遞配置參數(shù):

          interface?RPCMessageEventOptions?{
          ????currentEndpoint:?AbstractMessageReceiveEndpoint;
          ????targetEndpoint:?AbstractMessageSendEndpoint;
          ????config?:
          ????????|?((data:?any,?context:?AbstractMessageSendEndpoint)?=>?WindowPostMessageOptions)
          ????????|?WindowPostMessageOptions;
          }

          具體封裝實現(xiàn)可以戳這里看 RPCMessageEvent 的實現(xiàn),面向接口的設(shè)計可以很好將同一類問題歸一抽象,即使往后瀏覽器新增了新的通信機制,只要其還滿足這套接口配置,那我們的封裝就還是有效的。

          遠程過程調(diào)用(RPC)

          經(jīng)過上面的封裝我們得到一個基于事件驅(qū)動的消息通信工具,但這還不夠,因為其使用還較為原子化(原始),處理消息回復(fù)顯得繁瑣,舉個例子:

          import?{?RPCMessageEvent?}?from?'rpc-shooter';
          //?main
          const?mainEvent?=?new?RPCMessageEvent({
          ????currentEndpoint:?window,
          ????targetEndpoint:?iframe.contentWindow,
          ????config:?{
          ????????targetOrigin:?'*',
          ????},
          });
          mainEvent.on('reply:max',?(data)?=>?{
          ????console.log('invoke?max?result:',?data);
          });
          mainEvent.emit('max',?1,?2);

          //?child
          const?childEvent?=?new?RPCMessageEvent({
          ????currentEndpoint:?window,
          ????targetEndpoint:?window.top,
          });
          childEvent.on('max',?(a,?b)?=>?{
          ????const?result?=?Math.max(a,?b);
          ????childEvent.emit('reply:max',?result);
          });

          當(dāng) main 中調(diào)用 childmax 方法時還需要監(jiān)聽一個 child 中的回復(fù)(reply:max)事件,child 接受消息調(diào)用方法成功后也需要 emit 一個 reply:max 事件。這一來一回并不優(yōu)雅,眼不看為凈,還需要再做一層封裝包裝事件的觸發(fā)與響應(yīng)。

          promisify

          異步事件自然使用 Promise 比較合理,封裝也比較簡單:

          //?child
          function?registerMethod(method:?string,?handler:?RPCHandler)?{
          ????const?synEventName?=?`syn:${method}`;
          ????const?ackEventName?=?`ack:${method}`;
          ????const?synEventHandler?=?(data)?=>?{
          ????????Promise.resolve(handler(data.params))
          ????????????.then((result)?=>?{
          ????????????????this._event.emit(ackEventName,?result);
          ????????????});
          ????};
          ????this._event.on(synEventName,?synEventHandler);
          }
          registerMethod('max',?([a,b])?=>?Math.max(a,b));

          //?main
          function?invoke(method:?string,?params:?any):?Promise<any>?{
          ????return?new?Promise((resolve)?=>?{
          ????????const?synEventName?=?`syc:${method}`;
          ????????const?ackEventName?=?`ack:${method}`;
          ????????this._event.emit(synEventName,?params);
          ????????this._event.on(ackEventName,?(res)?=>?{
          ????????????resolve(res);
          ????????});
          ????});
          }
          invoke('max',?[1,2]).then((res)?=>?{
          ????console.log(res);
          });

          調(diào)用方 emit 一個帶有 syc: 前綴的事件,被調(diào)用方注冊并監(jiān)聽同名事件,消息調(diào)用成功后回復(fù)一個帶 ack: 前綴事件,調(diào)用方監(jiān)聽 ack: 事件標(biāo)識一次消息相應(yīng)成功,Promise.resolve。

          promisify 簡單,但實際使用消息通信會遇到各種各樣的問題:

          • 遠程方法調(diào)用錯誤
          • 調(diào)用方法不存在
          • 連接超時
          • 數(shù)據(jù)格式錯誤(如 worker 中錯誤傳遞了無法序列化 dom 對象)
          • ......

          針對通信過程各種情況我們需要將其描述出來。

          實際上網(wǎng)頁消息通信過程與 RPC 調(diào)用十分類似,可類比于調(diào)用遠程服務(wù)的方法。而剛好有個 JSON-RPC 協(xié)議規(guī)范可以十分簡單清晰描述此過程,不妨借來用一用。

          JSON-RPC

          JSON-RPC是一個無狀態(tài)且輕量級的遠程過程調(diào)用(RPC)協(xié)議。本規(guī)范主要定義了一些數(shù)據(jù)結(jié)構(gòu)及其相關(guān)的處理規(guī)則。它允許運行在基于socket,http等諸多不同消息傳輸環(huán)境的同一進程中。其使用JSON(RFC 4627)作為數(shù)據(jù)格式。

          相對動則幾百頁 http 協(xié)議規(guī)范,JSON-RPC 的規(guī)范很簡單,只有一頁,有興趣的同學(xué)可以研究下 JSON-RPC 2.0 規(guī)范。

          這里主要看一下 JSON-RPC 定義請求與響應(yīng)的數(shù)據(jù)格式:

          //?錯誤對象
          interface?RPCError?{
          ????code:?number;
          ????message:?string;
          ????data:?any;
          }

          //?RPC?請求對象
          interface?RPCSYNEvent?{
          ????jsonrpc:?'2.0';
          ????method:?string;
          ????params:?any;
          ????id?:?string;
          }

          //?RPC?響應(yīng)
          interface?RPCSACKEvent?{
          ????jsonrpc:?'2.0';
          ????result?:?any;
          ????error?:?RPCError;
          ????id?:?string;
          }

          帶索引數(shù)組參數(shù)的 rpc 調(diào)用:

          -->?{"jsonrpc":?"2.0",?"method":?"subtract",?"params":?[42,?23],?"id":?1}
          <--?{"jsonrpc":?"2.0",?"result":?19,?"id":?1}

          通知:

          -->?{"jsonrpc":?"2.0",?"method":?"update",?"params":?[1,2,3,4,5]}
          -->?{"jsonrpc":?"2.0",?"method":?"foobar"}

          不包含調(diào)用方法的rpc調(diào)用:

          -->?{"jsonrpc":?"2.0",?"method":?"foobar",?"id":?"1"}
          <--?{"jsonrpc":?"2.0",?"error":?{"code":?-32601,?"message":?"Method?not?found"},?"id":?"1"}
          f29d024d03b32c0a94b8460f8bbe25f.jpg

          規(guī)范中最重要的幾條規(guī)則如下:

          id

          已建立客戶端的唯一標(biāo)識id,值必須包含一個字符串、數(shù)值或NULL空值。如果不包含該成員則被認定為是一個通知。該值一般不為NULL[1],若為數(shù)值則不應(yīng)該包含小數(shù)[2]。

          每次調(diào)用需要有個唯一 id 標(biāo)識此次調(diào)用,因為我們可能會多次調(diào)用同一個遠程服務(wù),需要需要有個 id 來標(biāo)識每次調(diào)用。如果沒有 id 則表示調(diào)用方并不關(guān)心調(diào)用結(jié)果(表示此次調(diào)用是一次通知)。

          errorresult

          響應(yīng)對象必須包含result或error成員,但兩個成員必須不能同時包含。

          調(diào)用失敗返回 error,result 為空,調(diào)用成功返回 result,error 為空,有 error 對象時則表示調(diào)用失敗。

          JOSN-RPC 協(xié)議簡單明了描述數(shù)據(jù)請求與響應(yīng),我們只需要按照其要求封裝 Promise 調(diào)用,成功時 resolve 失敗時 reject 即可。

          封裝實現(xiàn)

          還是老規(guī)矩,先看一樣接口定義:

          interface?RPCHandler?{
          ????(...args:?any[]):?any;
          }

          interface?RPCEvent?{
          ????emit(event:?string,?...args:?any[]):?void;
          ????on(event:?string,?fn:?RPCHandler):?void;
          ????off(event:?string,?fn?:?RPCHandler):?void;
          }

          interface?RPCInitOptions?{
          ????event:?RPCEvent;
          ????methods?:?Record<string,?RPCHandler>;
          ????timeout?:?number;
          }

          interface?RPCInvokeOptions?{
          ????isNotify:?boolean;
          ????timeout?:?number;
          }

          declare?class?RPC?{
          ????private?_event;
          ????private?_methods;
          ????static?uuid():?string;
          ????constructor(options:?RPCInitOptions);
          ????registerMethod(method:?string,?handler:?RPCHandler):?void;
          ????removeMethod(method:?string):?void;
          ????invoke(method:?string,?params:?any,?options?:?RPCInvokeOptions):?Promise<any>;
          }

          具體封裝可看 RPC 實現(xiàn),最終 RPC 工具方式如下:

          //?main.ts
          import?{?RPCMessageEvent,?RPC?}?from?'rpc-shooter';

          (async?function?()?{
          ????const?iframe?=?document.querySelector('iframe')!;
          ????const?rpc?=?new?RPC({
          ????????event:?new?RPCMessageEvent({
          ????????????currentEndpoint:?window,
          ????????????targetEndpoint:?iframe.contentWindow!,
          ????????????config:?{?targetOrigin:?'*'?},
          ????????}),
          ????????//?初始化時注冊處理函數(shù)
          ????????methods:?{
          ????????????'Main.max':?(a:?number,?b:?number)?=>?Math.max(a,?b),
          ????????},
          ????});
          ????//?動態(tài)注冊處理函數(shù)
          ????rpc.registerMethod('Main.min',?(a:?number,?b:?number)?=>?{
          ????????return?Promise.resolve(Math.min(a,?b));
          ????});

          ????//?調(diào)用?iframe?服務(wù)中的注冊方法
          ????const?randomValue?=?await?rpc.invoke('Child.random',?null,?{?isNotify:?false,?timeout:?2000?});
          ????console.log(`Main?invoke?Child.random?result:?${randomValue}`);
          })();
          //?child.ts
          import?{?RPCMessageEvent,?RPC?}?from?'rpc-shooter';
          (async?function?()?{
          ????const?rpc?=?new?RPC({
          ????????event:?new?RPCMessageEvent({
          ????????????currentEndpoint:?window,
          ????????????targetEndpoint:?window.top,
          ????????}),
          ????});

          ????rpc.registerMethod('Child.random',?()?=>?Math.random());

          ????const?max?=?await?rpc.invoke('Main.max',?[1,?2]);
          ????const?min?=?await?rpc.invoke('Main.min',?[1,?2]);
          ????console.log({?max,?min?});
          })();

          有一點需要注意以下,在 RPC 初始化實際我們只依賴 RPCEvent 接口,瀏覽器的通信是由 RPCMessageEvent 模塊實現(xiàn)的,我們也可將其換成其他的業(yè)務(wù)實現(xiàn),如使用 socket.io 來替代 RPCMessageEvent 以達到和服務(wù)端通信的目的,又一個面向接口開發(fā)的好處。

          至此我們完成從基本消息通信到頁面 RPC 服務(wù)調(diào)用的封裝,對實現(xiàn)細節(jié)有興趣的同學(xué)可以戳:rpc-shooter 歡迎指教。

          附注:Google 專業(yè)解決 worker 調(diào)用的工具庫 comlink,有生產(chǎn)需要同學(xué)可以試試。

          其他

          rpc-shooter 的開發(fā)過程學(xué)到不少東西,也是目前自己寫得比較上心的一個小工具,有膽大小伙伴不妨來試試。

          個人感受是:

          • TS 真香
          • 接口優(yōu)先、接口優(yōu)先、還是接口優(yōu)先

          ???H5-Dooring,讓H5制作更簡單

          ???

          便內(nèi),^_^

          ?、、?~。

          關(guān)號?趣談前端?獲前端~

          瀏覽 79
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <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>
                  啊啊啊插笔网站 | 伊人97 | 波多野结衣AV一区二区 | 波多野结av衣东京热无码专区 | 麻豆成人av影院 漫画视频搞黄网站 |