【JS 】前端跨頁(yè)面通訊實(shí)戰(zhàn)(LinkCom.js最完整設(shè)計(jì)說(shuō)明文檔)

知識(shí)點(diǎn)

  1. window.postMessage 跨頁(yè)消息
  2. Promise 使用技巧 async/await
  3. 保護(hù)數(shù)據(jù)的三種方式和應(yīng)用場(chǎng)景 definePropertyObject.freezeProxy
  4. es5中的類(lèi)繼承
  5. rpc 實(shí)現(xiàn)原理
  6. 其他知識(shí)點(diǎn)

    參數(shù)歸一化,解構(gòu),展開(kāi),惰性函數(shù),立即執(zhí)行函數(shù)箭頭函數(shù),閉包async/await

2. 原始設(shè)計(jì)

參考RPC框架的思路,設(shè)計(jì)一個(gè)前端跨頁(yè)通訊組件 LinkCom - 領(lǐng)航

3. 類(lèi)模塊設(shè)計(jì)(UML)

4. 時(shí)序圖

5. 類(lèi)模塊功能解釋

提示:后續(xù)內(nèi)容中示例代碼目的是展示核心邏輯,會(huì)刪除例如參數(shù)校驗(yàn),文檔注釋等代碼

5.1 幫助函數(shù)

5.1.1 createId

const id = createId();

用于創(chuàng)建一個(gè)隨機(jī)id

  • 除了隨機(jī)數(shù)以外,增加num變量每次遞增,進(jìn)一步減少重復(fù)概率;
  • 使用閉包IIFE,減少私有變量num對(duì)其他代碼影響
const createId = (function () {
    let num = 0;
    return () => (++num) + ":" + Math.random().toString(36).substring(2);
})();

5.1.2 freezeDeep

freezeDeep(obj)

用于深度凍結(jié)對(duì)象 普通的對(duì)象凍結(jié)只能保證當(dāng)前對(duì)象不被修改,但無(wú)法保證對(duì)象包含的子對(duì)象也無(wú)法修改 使用遞歸的方式將對(duì)象和對(duì)象中的屬性都凍結(jié)掉

const freezeDeep = (function () {
    function freeze(obj) {
        Object.freeze(obj);
        for (const key in obj) {
            if (typeof obj[key] === "object" && obj[key] != null) {
                freeze(obj[key]);
            }
        }
    }
    return (obj) => {
        freeze(obj);
        return obj;
    };
})();
  • 名詞解釋

    對(duì)象凍結(jié):這項(xiàng)功能一般用在需要保護(hù)的數(shù)據(jù)對(duì)象上,被凍結(jié)的對(duì)象將無(wú)法修改對(duì)中的任何屬性,也無(wú)法追加屬性,如果是數(shù)組,也同樣無(wú)法向數(shù)組中添加元素,對(duì)象凍結(jié)可以有效防止數(shù)據(jù)被其他代碼篡改。

5.1.3 log(message, ...args)

log && log("打印日志");

用于打印日志

let log = function (message, ...args) {
    if (window.location.hostname === "localhost") {
        log = function (message, ...args) {
            if (message instanceof Error) {
                console.trace();
            }
            console.log(`${window.document.title} - ${new Date().toLocaleTimeString()} LinkCom.${message}`, ...args);
        };
        log(message, ...args);
    } else {
        log = null;
    }
};
  • 名詞解釋:

    惰性函數(shù):這是一種開(kāi)發(fā)技巧,表示函數(shù)執(zhí)行的分支只會(huì)在函數(shù)第一次調(diào)用的時(shí)候判斷,在第一次調(diào)用過(guò)程中,該函數(shù)會(huì)被覆蓋為另一個(gè)按照合適方式執(zhí)行的函數(shù),這樣任何對(duì)原函數(shù)的調(diào)用就不用再經(jīng)過(guò)執(zhí)行的分支了
    參數(shù)展開(kāi):在這個(gè)例子中參數(shù)...args表示在調(diào)用時(shí),第1個(gè)之后的參數(shù)會(huì)被包裝為一個(gè)數(shù)組,例如調(diào)用log(1,2,3)message=1,args=[2,3];參數(shù)展開(kāi)還有更多用法,可以自行查閱文檔

5.2 幫助類(lèi)

5.2.1 信號(hào)量 Semaphores

const ss = new Semaphores();
ss.resolve("完成");

表示一個(gè)信號(hào)量, 繼承自 Promise, 自身包含 resolvereject 函數(shù), 可以直接解決或拒絕 Promise

class Semaphores extends Promise {
    constructor(...args) {
        if (args.length === 0 || (args.length === 1 && typeof args[0] === "object")) {
            const hander = Object.assign({}, args[0]);
            super((resolve, reject) => {
                hander.resolve = resolve;
                hander.reject = reject;
            });
            Object.assign(this, hander);
            freezeDeep(this);
        } else {
            super(...args);
        }
    }
}
  • 名詞解釋:

    Promise:通常用來(lái)表示一個(gè)可以由人為控制的異步操作,可以通過(guò)主動(dòng)調(diào)用Promise構(gòu)造函數(shù)中的resolvereject來(lái)結(jié)束異步等待;
    調(diào)用resolve并傳入一個(gè)任意對(duì)象表示異步完成,調(diào)用reject并傳入一個(gè)Error表示異步操作出現(xiàn)異常。
    同時(shí) Promise 還可以由[async/await](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/async_function)關(guān)鍵觸發(fā)異步等待操作。

5.2.2 異步隊(duì)列 AsyncQueue

const queue = new AsyncQueue();
queue.push(123);
const value = await queue.shift();

表示一個(gè)先進(jìn)先出的異步隊(duì)列; 使用push(data)存入數(shù)據(jù), 使用異步方法async shift()取出數(shù)據(jù), 當(dāng)沒(méi)有數(shù)據(jù)時(shí), 會(huì)等待, 直到數(shù)據(jù)被push方法存入
由于 class 的私有屬性兼容性不好, 希望對(duì)外隱藏list的操作,所以使用閉包代替class
首先初始化會(huì)在內(nèi)部列表中放入一個(gè)等待中的信號(hào)

  • push:獲取到列表中最后一個(gè)信號(hào),并將其標(biāo)識(shí)為已解決,然后將一個(gè)新的等待中信號(hào)放入列表尾部
  • shift:等待列表中一個(gè)信號(hào)被處理(解決或拒絕),然后將信號(hào)從列表第一位移除,這里需要注意,shift可以多次調(diào)用,可能存在多個(gè)等待中的操作,當(dāng)?shù)谝粋€(gè)等待結(jié)束后,會(huì)將信號(hào)移除,之后的等待操作結(jié)束必須判斷目前列表第一位的數(shù)據(jù)是否還是等待前取出的信號(hào),如果不是,則說(shuō)明已經(jīng)由其他操作移除了,不用再進(jìn)行任何操作
  • destroy:銷(xiāo)毀列表時(shí),向所有列表中的信號(hào)發(fā)送拒絕,并附上原因
function AsyncQueue() {
    const list = [new Semaphores()];
    this.shift = async () => {
        const first = list[0];
        const data = await first;
        if (first === list[0]) {
            list.shift();
        }
        return data;
    }
    this.push = data => {
        const last = list[list.length - 1];
        last.resolve(data);
        list.push(new Semaphores());
    }
    this.count = () => list.length;
    this.destroy = reason => {
        const err = reason instanceof Error ? reason : new Error(reason || "queue is destroyed");
        list.forEach(x => x.reject(err));
        list.length = 0;
    }
}
  • 名詞解釋:

參數(shù)歸一化:是將不同類(lèi)型的數(shù)據(jù)變?yōu)橄嗤?lèi)型,以方便后續(xù)處理的一種技巧
例如:const err = reason instanceof Error ? reason : new Error(reason || "queue is destroyed");
以上操作就是無(wú)論 reason 現(xiàn)在是什么類(lèi)型,都轉(zhuǎn)為 Error 類(lèi)型,后續(xù)操作只需要操作 Error 就可以了

5.2.3 任務(wù)板 TaskBoard

const tasks = new TaskBoard(); 
tasks.put(message.id, new Semaphores({ message, targetToken: handler.targetToken }), timeout);
tasks.finish(message.id, message.result);

表示一個(gè)任務(wù)板, 任務(wù)為 Semaphores 類(lèi)型
每個(gè)存入的任務(wù)都有一個(gè)超時(shí)時(shí)間, 超時(shí)后自動(dòng)刪除, 并將 Semaphores 解決為錯(cuò)誤
每個(gè)存入的任務(wù)可在超時(shí)前被完成(完成可以是解決或拒絕), 完成后自動(dòng)刪除
同樣的為了隱藏 tasks 使用閉包

  • 方法:
    • put:添加任務(wù)時(shí),構(gòu)造一個(gè)setTimeout用于控制超時(shí)時(shí)間,超時(shí)后直接調(diào)用完成方法傳入Error
    • finish:完成任務(wù)方法中判斷結(jié)果值,如果是Error執(zhí)行reject
    • take:取出任務(wù)的時(shí),將任務(wù)從tasks中刪除,并刪除定時(shí)器
function TaskBoard() {
    const tasks = {};
    this.put = (id, task, timeout) => {
        const timeoutId = setTimeout(() => this.finish(id, new Error("timeout")), timeout);
        tasks[id] = { content: task, timeoutId };
    };
    this.take = id => {
        const data = tasks[id];
        clearTimeout(data.timeoutId);
        delete tasks[id];
        return data.content;
    };
    this.has = id => id in tasks;
    this.count = () => Object.keys(tasks).length;
    this.clear = () => Object.keys(tasks).forEach(id => this.take(id));
    this.get = id => this.has(id) ? tasks[id].content : null;
    this.finish = (id, result) => {
        const task = this.take(id);
        const exec = result instanceof Error ? task.reject : task.resolve;
        exec(result);
    };
}

5.3 消息類(lèi)

要實(shí)現(xiàn)類(lèi)似RPC的通信功能,首先要設(shè)計(jì)的就是消息類(lèi)結(jié)構(gòu) 消息類(lèi)分為 請(qǐng)求(RequestMessage)和響應(yīng)(ResponseMessage) 兩種類(lèi)型的消息會(huì)有一些共同的特點(diǎn),所以抽象出消息基類(lèi)(Message

5.3.1 消息基類(lèi) Message

消息作為一個(gè)DTO(Data Transfer Object 數(shù)據(jù)傳輸對(duì)象),創(chuàng)建后就不應(yīng)再被更改,所以執(zhí)行freezeDeep將其保護(hù)起來(lái) 考慮到繼承的問(wèn)題,子類(lèi)也無(wú)法對(duì)其進(jìn)行更改,所以在構(gòu)造函數(shù)中需要傳入子類(lèi)的屬性,由基類(lèi)執(zhí)行屬性綁定操作

  • 屬性:

    • type:消息類(lèi)型 ("request""response")
    • token:用于標(biāo)識(shí)發(fā)送消息的程序的身份
    • id:每個(gè)請(qǐng)求消息都會(huì)生成唯一的id,而響應(yīng)消息的id取自請(qǐng)求消息id,表示響應(yīng)的是哪個(gè)請(qǐng)求
    • *:任意擴(kuò)展屬性,用于綁定子類(lèi)屬性
  • 方法:

    • buildMessage():構(gòu)建可用于發(fā)送的消息體,默認(rèn)實(shí)現(xiàn)Object.assign({}, this);可由子類(lèi)重寫(xiě)
    • static parse(Object):靜態(tài)方法,用于將任意對(duì)象轉(zhuǎn)為Message
class Message {
    constructor(token, type, id, props) {
        Object.assign(this, props, { token, type, id });
        freezeDeep(this);
    }
    
    buildMessage(){
        return Object.assign({}, this);
    }
    
    static parse(message) {
        const { token, type, id } = message;
        return new Message(token, type, id, message);
    }
}
  • 名詞解釋:

    類(lèi) :前端 ES5 之后也提供了與后端類(lèi)似的class聲明的方式,語(yǔ)法和代碼更為純粹

5.3.2 請(qǐng)求消息 RequestMessage

const message = new RequestMessage(handler.token, "call", { method, args })

請(qǐng)求消息,用于 主動(dòng)調(diào)用方 發(fā)給 被動(dòng)接收方 時(shí)的消息

  • 屬性:
    • token:生成消息的處理程序token,用于接收方對(duì)消息進(jìn)行來(lái)源認(rèn)證
    • command:消息命令,用于接收方確定使用何種方式處理
    • data data:消息數(shù)據(jù)
class RequestMessage extends Message {
    constructor(token, command, data) {
        super(token, "request", createId(), { command, data });
    }
}
  • 名詞解釋

    繼承:extends 關(guān)鍵字用于表示類(lèi)之間的繼承關(guān)系,在構(gòu)造函數(shù)中使用super關(guān)鍵字來(lái)調(diào)用父類(lèi)的構(gòu)造函數(shù)

5.3.3 響應(yīng)消息 ResponseMessage

const message = new ResponseMessage(handler.token, id, result);

響應(yīng)消息,用于 被動(dòng)接收方 收到消息并處理完成后將返回值發(fā)送給 主動(dòng)調(diào)用方 時(shí)的消息

  • 屬性:
    • id:請(qǐng)求消息id,用于調(diào)用方確定當(dāng)前響應(yīng)是回復(fù)哪一個(gè)請(qǐng)求消息
    • result:響應(yīng)結(jié)果
class ResponseMessage extends Message {
    constructor(token, id, result) {
        super(token, "response", id, { result });
    }
}

5.4 消息處理程序 MessageHandler

const handler = new MessageHandler(methods, url, window);
handler.onmessage = (message, event) => { };
handler.send(message);
handler.apply(data.method, handler, [...data.args, event]);

提供最基礎(chǔ)的發(fā)送消息方法和接收消息回調(diào)
處理程序與Message不同,并不是所有屬性都是凍結(jié)的 其中targetUrl,targetUrltargetWindow都需要后期設(shè)置,但設(shè)置時(shí)需要驗(yàn)證值的類(lèi)型,所以使用屬性劫持defineProperty)來(lái)處理,其中targetUrl也使用了參數(shù)歸一化的技巧,將StringURL都轉(zhuǎn)為URL,方便后續(xù)使用;

  • 屬性:
    • token:消息處理程序token,該屬性自動(dòng)生成只讀,身份標(biāo)識(shí),在發(fā)送消息時(shí)需要設(shè)置到
    • Message,用于接收方識(shí)別身份
    • methods:注冊(cè)為允許遠(yuǎn)程調(diào)用方法
    • targetToken:目標(biāo)token,該屬性用于接收消息時(shí)驗(yàn)證消息來(lái)源
    • targetUrl:目標(biāo)url
    • targetWindow:目標(biāo)window
  • 函數(shù):
    • send(Message):發(fā)送消息
    • onmessage:接收消息回調(diào),模擬window.onmessage的行為,將事件參數(shù)改為[Message, Event]方便語(yǔ)義理解 apply(methodName, that, args):執(zhí)行已注冊(cè)的方法,如果方法不存在則返回 undefined
class MessageHandler {
    constructor(methods, url, win) {
        Object.defineProperties(this, {
            token: { value: createId() },
            methods: { value: methods },
            targetUrl: {
                get: () => url && url.origin,
                set(value) {
                    if (url instanceof URL) {
                        url = value;
                    } else if (typeof value === "string") {
                        url = new URL(value);
                    } else {
                        throw new Error("url is not URL");
                    }
                }
            },
            targetWindow: {
                get: () => win,
                set(value) => win = value;
            },
        });
        if (win != null) {
            this.targetWindow = win;
        }
        if (url != null) {
            this.targetUrl = url;
        }
        window.addEventListener("message", event => {
            const message = Message.parse(event.data);
            this.onmessage(message, event);
        }, false);
    }

    send(message) {
        const data = message.buildMessage();
        this.targetWindow.postMessage(data, this.targetUrl);
    }

    targetToken = null;

    onmessage = null;

    apply(that, method, args) {
        const fn = this.methods[method];
        if (typeof fn !== "function") {
            return undefined;
        }
        const result = fn.apply(that, args);
        return result === undefined ? null : result;
    }
}
  • 名詞解釋

    defineProperty:屬性劫持,與對(duì)象凍結(jié)一樣,也是一種保護(hù)數(shù)據(jù)的手段,他們的區(qū)別在于,屬性劫持可以更靈活的控制那些屬性需要保護(hù),需要怎樣的保護(hù);而對(duì)象凍結(jié)是將整個(gè)對(duì)象所有的屬性都保護(hù)起來(lái)

5.5 收發(fā)客戶端 Client

const client = new Client(handler, timeout);
const token = await client.request("$ask", data);
const { message, event } = await client.receive(); const result = commands[message.command](message.data, event); client.response(message.id, result);
const invoker = client.build();

對(duì)消息處理程序進(jìn)行封裝,對(duì)外提供封裝后的新函數(shù)

  • 私有屬性:
    • handler:消息處理程序(MessageHandler),用于發(fā)出和接收消息
    • inbox:先進(jìn)先出隊(duì)列(AsyncQueue), 表示一個(gè)收件箱,用于存放收到的情況,由另一個(gè)線程取出數(shù)據(jù)操作
    • tasks:任務(wù)板(TaskBoard),用于存放發(fā)出的請(qǐng)求,收到回復(fù)或超時(shí)后從任務(wù)板中移除
  • 方法:
    • async request(command, data, unresponse):發(fā)送請(qǐng)求消息,第三個(gè)參數(shù)表示是否忽略響應(yīng)
    • response(id, result):發(fā)送響應(yīng)消息
    • async receive():等待接收消息
    • close():關(guān)閉客戶端
    • build():編譯一個(gè)用于調(diào)用遠(yuǎn)程方法的代理對(duì)象
function Client(handler, timeout) {
    timeout = Math.max(100, parseInt(timeout) || 1000);
    const inbox = new AsyncQueue();
    const tasks = new TaskBoard();
    const beforeunload = () => handler.targetWindow && this.request("close", null, true);
    window.addEventListener("beforeunload", beforeunload, false);
    handler.onmessage = (message, event) => {
        switch (message.type) {
            case "request":
                inbox.push({ message, event });
                break;
            case "response":
                tasks.finish(message.id, message.result);
                break;
            default:
                return;
        }
    };

    this.close = function (reason) {
        window.removeEventListener("beforeunload", beforeunload);
        window.removeEventListener("message", onmessage, false);
        // 清理資源
        handler.onmessage = null;
        tasks.clear();
        inbox.destroy(reason || "主動(dòng)斷開(kāi)");
    }

    this.request = async function (command, data, unresponse) {
        const message = new RequestMessage(handler.token, command, data);
        const content = new Semaphores({ message, targetToken: handler.targetToken });
        if (unresponse !== true) {
            tasks.put(message.id, content, timeout);
        }
        handler.send(message);
        return await content;
    }

    this.response = (id, result) => handler.send(new ResponseMessage(handler.token, id, result));

    this.builder = () => {
        const invoker = new Proxy({}, {
            get: (target, prop) => {
                // 如果是 MessageHandler 屬性, 則直接返回
                if (["targetUrl", "targetWindow", "token", "targetToken", "then", "catch", "finally"].includes(prop)) {
                    return handler[prop];
                }
                return (...args) => this.request("call", { method: prop, args });
            }
        });

        const methods = handler.methods;

        const commands = { /* 命令部分代碼省略... */ }

        async function execRequestMessage(message, event) {
            const { command, data } = message;
            const fn = commands[command];
            try {
                return fn(data, event);
            } catch (error) {
                return new Error(error.message || "error");
            }
        }

        // 循環(huán)接收消息
        (async () => {
            while (true) {
                const { message, event } = await inbox.shift();
                window.requestIdleCallback(async () => {
                    const result = await execRequestMessage(message, event);
                    if (result !== undefined) {
                        this.response(message.id, result);
                    }
                });
            }
        })();

        return invoker;
    }
}
  • 名詞解釋:

    箭頭函數(shù):箭頭函數(shù)可以忽略函數(shù)中的this,直接使用當(dāng)前作用的this對(duì)象,在閉包中使用,可以忽略調(diào)用者對(duì)作用域的影響
    Proxy:動(dòng)態(tài)代理與 defineProperty最大的不同點(diǎn)在于他可以劫持一個(gè)對(duì)象中的所有操作,而不僅僅是已知屬性,但會(huì)產(chǎn)生一個(gè)新對(duì)象,而 defineProperty不會(huì)產(chǎn)生新的對(duì)象; 在builder方法里對(duì)Messagehandler進(jìn)行動(dòng)態(tài)代理,可以讓用戶方便的調(diào)用遠(yuǎn)端的方法。

5.6 領(lǐng)航組件主入口 LinkCom

// 主動(dòng)連接
const invoker = await new LinkCom(methods).connect(win, url, {}); // 創(chuàng)建跨站調(diào)用器, 主動(dòng)發(fā)出連接申請(qǐng)
const res = await invoker.hello("world");
invoker.close()

// 被動(dòng)連接
const methods = {
    connect() {
        this.hello("world");
    },
    hello(msg){
        alert(msg);
        return "你也好";
    },
    close(){
        // 已斷開(kāi)
    }
};
new LinkCom(methods).wait();  // 創(chuàng)建跨站調(diào)用器, 并等待連接

主入口,用戶使用組件從該對(duì)象開(kāi)始

function LinkCom(methods) {
    this.connect = async function (window, url, data) {
        try {
            const handler = new MessageHandler(methods, url, window);
            const client = new Client(handler);
            const invoker = client.builder();
            const result = await client.request("connect", handler.token);
            if (result) {
                return invoker;
            }
        } catch (error) {
            throw new Error("拒絕連接:" + error.message);
        }
        throw new Error("拒絕連接");
    }
    this.wait = function () {
        const handler = new MessageHandler(methods);
        new Client(handler).builder();
        return this;
    }
}

6. 最終效果



7. 完整代碼:

LinkCom.js

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容