2021/10/15

JS DataSnap framework in Webpack

 


Webpack 5 初探筆記】這篇記錄了 Webpack 三項重點:

  • JS 入口
  • HTML Page 建立及嵌入 JS 設定。
  • 除錯模式

你以為已經很多了?不,還有線上除錯服務器 (Webpack devServer) 還沒用到,該篇文章僅就編譯功能處理,編譯完成後再到實機裡除錯。

所以本篇就來研究如何讓 Webpack devServer 和後端 WebAPI 結合,本篇文章會講到兩個重點:

  • 使用 devServer Proxy 來解決【跨來源資源共用(CORS)】問題。
  • JS DataSnap framework 修改呼叫 DataSnap API 的方式。

使用 devServer Proxy 來解決【跨來源資源共用(CORS)】問題

devServer 的基本功能是提供網頁服務,熱編譯重讀 (hot reload) 功能則是在前端網頁開發時省時好幫手,檔案一儲存畫面就立即更新,這能省下許多重新編譯的時間,所以這次就把使用它為目標。

webpack 設定 devServer 非常簡單,內容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
module.exports = {
  ...
    devServer: {
        static: {
            directory: path.join(__dirname, 'dist'),
        },
        hot: true,
        port: 9000,
    }
  ...
}

接著在 index.js 裡寫上範例程式碼:

1
2
3
setConnection("localhost", "8080", "")
var oldExecutor = new ServerFunctionExecutor("TServerMethods1", connectionInfo);
console.log("EchoString : ", oldExecutor.executeMethod("EchoString", "GET", ["A B C"]))

網頁在啟動後會出現錯誤:


已封鎖跨來源請求: 同源政策不允許讀取】這句話的翻譯就是:後端網頁伺服器未對 CORS 做出對應的處理,在這裡指的便是 DataSnap 伺服器。

Eden 的【Delphi in Depth DataSnap 網站應用程式全端開發】一書中所提到的 CORS 解決方案其實就是要解決前端伺服器呼叫 DataSnap 伺服器上的 API 所產生的問題,有興趣的開發者邀請你購買起來看,保證物超所值!

devServer 對 CORS 的處理方式:Proxy

來看看 Webpack 官網對 Proxy 是怎麼寫的:

当拥有单独的 API 后端开发服务器并且希望在同一域上发送 API 请求时,代理某些 URL 可能会很有用。

「擁有單獨的 API 後端開發服務器」以本篇指的就是 DataSnap 伺服器,所以按照官網的範例調整後,webpack.config.js 內容為:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    devServer: {
        static: {
            directory: path.join(__dirname, 'dist'),
        },
        hot: true,
        port: 9000,
        proxy:{
            "/datasnap/rest":"http://localhost:8080"
        }
    },

現在,http://localhost:9000/datasnap/rest 的網址就會對應到 http://localhost:8080/datasnap/rest,完成後就來試一下:


設定成功,現在可以順利在開發時期輕鬆呼叫 DataSnap API! 😉


JS DataSnap framework 修改呼叫 DataSnap API 的方式

我在【JavaScript ES6 call DataSnap API with Promise Fetch】寫到使用 fetch 來取代 ServerFunctionExecutor.executeMethodUrl 裡的 XMLHttpRequest,但為了避免回呼地獄 (callback hell) 而放棄 ServerFunctionExecutor 整個類別,必須要說,我真的很喜歡 EMBT 寫的 ServerFunctionExecutor 類別,它不僅是我學習 JavaScript 的啟蒙,而且對瀏覽器相容性也達到 100%,連 IE6 都相容,我真的愛死 ServerFunctionExecutor 類別了!

修改 ServerFunctionExecutor.js 原始碼是一種方法,但每一版的 Delphi 對 ServerFunctionExecutor 類別都有或多或少的修改,我認為最好的方案就是【繼承】,既然要用 fetch,也表示要放棄對 IE 的相容性,那採用 JavaScript ES6/7 新標準也是可以的,程式碼如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import "./dsjs/connection"
import "./dsjs/ServerFunctionExecutor"

class DSFunctionExecutor extends ServerFunctionExecutor {
    constructor(className, connectionInfo, owner) {
        super(className, connectionInfo, owner)
    }

    /**
     * This function executes the given method with the specified parameters and then
     * notifies the Promise when a response is received.
     * @param url the url to invoke
     * @param contentParam the parameter to pass through the content of the request (or null)
     * @param requestType must be one of: GET, POST, PUT, DELETE
     * @param hasResult true if a result from the server call is expected, false to ignore any result returned.
     *                  This is an optional parameter and defaults to 'true'
     * @param accept The string value to set for the Accept header of the HTTP request, or null to set as application/json
     * @return This function will return the result that would have otherwise been passed to the Promise.
     */
    async #fetchMethodURL(url, contentParam, requestType, hasResult, accept) {
        if (hasResult == null) hasResult = true;

        requestType = validateRequestType(requestType);

        const fetchHeaders = new Headers();
        fetchHeaders.append("Accept", (accept == null ? "application/json" : accept));
        fetchHeaders.append("Content-Type", "text/plain;charset=UTF-8");
        fetchHeaders.append("If-Modified-Since", "Mon, 1 Oct 1990 05:00:00 GMT");
        const sessId = getSessionID();
        if (sessId != null)
            fetchHeaders.append("Pragma", "dssession=" + sessId);
        if (this.authentication != null)
            fetchHeaders.append("Authorization", "Basic " + this.authentication);

        const fetchParams = {
            method: requestType,
            body: contentParam,
            headers: fetchHeaders,
        }
        try {
            const response = await fetch(url, fetchParams)
            this.#parseFetchSessionID(response);
            const responseText = await response.text();
            let JSONResultWrapper = null;
            try {
                JSONResultWrapper = JSON.parse(responseText);
            }
            catch (e) {
                JSONResultWrapper = responseText;
            }
            if (response.status == 403) {
                if (JSONResultWrapper != null && JSONResultWrapper.SessionExpired != null) {
                    //the session is no longer valid, so clear the stored session ID
                    //a new session will be creates the next time the user invokes a server function
                    setSessionData(null, null);
                }
            }
            //all other results (including other errors)
            //return JSONResultWrapper;
            const returnObject = JSONResultWrapper;
            if (returnObject != null && returnObject.result != null && Array.isArray(returnObject.result)) {
                return returnObject.result[0];
            }
            return returnObject;
        }
        catch (err) {
            console.error('Error:', err)
            return err
        }
    };

    /**
     * This function executes the given method with the specified parameters and then
     * notifies the callback when a response is received.
     * @param methodName the name of the method in the class to invoke
     * @param requestType must be one of: GET, POST, PUT, DELETE
     * @param params an array of parameter values to pass into the method, or a single parameter value
     * @param hasResult true if a result from the server call is expected, false to ignore any result returned.
     *                  This is an optional parameter and defaults to 'true'
     * @param requestFilters JSON Object containing pairs of key/value filters to add to the request (filters such as ss.r, for example.)
     * @param accept The string value to set for the Accept header of the HTTP request, or null to set application/json
     * @return This function will return the result that would have otherwise been passed to the Promise.
     */
    async fetchMethod(methodName, requestType, params, hasResult, requestFilters, accept) {
        const url = this.getMethodURL(methodName, requestType, params, requestFilters);
        return await this.#fetchMethodURL(url[0], url[1], requestType, hasResult, accept);
    };

    /**
     * Tries to get the session ID from the Pragma header field of the given request/response object
     * If successful, will set the value of the $$SessionID$$ and $$SessionExpires$$ variables accordingly.
     * @param response the response from the http request
     */
    #parseFetchSessionID(response) {
        if (response != null) {
            //pragma may store the Session ID value to use in future calls
            var pragmaStr = response.headers.get("Pragma");

            if (pragmaStr != null) {
                //Header looks like this, if set: Pragma: dssession=$$SessionID$$,dssessionexpires=$$SessionExpires$$
                var sessKey = "dssession=";
                var expireKey = "dssessionexpires=";
                var sessIndx = pragmaStr.indexOf("dssession=");

                if (sessIndx > -1) {
                    var commaIndx = pragmaStr.indexOf(",", sessIndx);
                    commaIndx = commaIndx < 0 ? pragmaStr.length : commaIndx;
                    sessIndx = sessIndx + sessKey.length;
                    var sessionId = pragmaStr.substr(sessIndx, (commaIndx - sessIndx));

                    var sessionExpires = null;
                    var expiresIndx = pragmaStr.indexOf(expireKey);
                    if (expiresIndx > -1) {
                        commaIndx = pragmaStr.indexOf(",", expiresIndx);
                        commaIndx = commaIndx < 0 ? pragmaStr.length : commaIndx;
                        expiresIndx = expiresIndx + expireKey.length;
                        var expiresMillis = parseInt(pragmaStr.substr(expiresIndx, (commaIndx - expiresIndx)));
                        if (expiresMillis != 0 && expiresMillis != NaN) {
                            sessionExpires = new Date();
                            sessionExpires.setMilliseconds(sessionExpires.getMilliseconds() + expiresMillis);
                        }
                    }

                    setSessionData(sessionId, sessionExpires);
                }
            }
        }
    }
}

程式碼說明

因 fetch 回傳的 response 和 XMLHttpRequest 的不同,所以抄了原來的 executeMethodURL、 parseSessionID 為 #fetchMethodURL 和 #parseFetchSessionID,加了「#」是為私有屬性,以避免免被其它開發者誤用。

應用程式大多需要同步處理以得到較好的操作性,故使用 async / await 等待 response 回來。

修改和執行結果如下:

這裡要留意 Webpack Babel 外掛在編譯這類別的時候可能會出現【regeneratorRuntime is not defined】錯誤訊息,原因很複雜,【Webpack 前端打包工具 - 使用 babel-loader 編譯並轉換 ES6+ 代碼 】提供了說明,而解決方式很簡單:

安裝 Babel transform-runtime 外掛:

npm install --save-dev @babel/plugin-transform-runtime
npm install --save @babel/runtime
//babel.config.json


{
"presets": ["@babel/preset-env"], "plugins": ["@babel/transform-runtime"]
}


本次 npm 用到的指令

npm init
npm i webpack -D
npm install --save-dev css-loader
npm install --save-dev style-loader
npm install --save-dev html-webpack-plugin
npm install --save-dev mini-css-extract-plugin
npm install -D babel-loader @babel/core @babel/preset-env webpack
npm install --save-dev clean-webpack-plugin
npm install copy-webpack-plugin --save-dev
// 排除 ES6 太新語法造成 "Babel 7 - ReferenceError: regeneratorRuntime is not defined"
// https://stackoverflow.com/questions/53558916/babel-7-referenceerror-regeneratorruntime-is-not-defined
npm install --save-dev @babel/plugin-transform-runtime
npm install --save @babel/runtime

結論時間

透過這次 Webpack 的練習,發現只要 JS 該引用的單元都正確引用,Visual Studio Code 都能夠正確追蹤到程式碼來源或 JS DOC,尤其是在追蹤 JS DataSnap framework,VS Code 真的有效加強學習的效果,向你推薦!

本次練習專案我放在 Github 上,連結在 See also 區,有興趣的朋友歡迎來看看。 😉


See also

2021/10/08

Webpack 5 初探筆記

 


在寫完【還在 React 從入門到放棄?告訴你選擇比努力還重要的事】這篇文章之後,原本要繼續我的 React 裸奔之旅,但看到【【前端速成】Webpack5 快速入門|Tiktok工程師帶你入門前端|布魯斯前端】的直播內容,發現 Webpack 和 Delphi 的專案檔實在太像了,手癢忍不住動手把自己的 jQuery 網站利用 Webpack 重新打包順道重構,過程很有趣,而且整理後的專案內容就像是看網站地圖一樣,看了心情大好。

趁還有記憶時把 Webpack 的參數檔和 NPM package 參數檔透過這偏文章寫下來。

Package.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// package.json
{
  "name": "eden_first_project",
  "version": "1.0.0",
  "description": "",
  "main": "maindesk.js",
  "scripts": {
    "build": "SET NODE_ENV=production && webpack",
    "design_build": "SET NODE_ENV=development && webpack && copyfile_with_debug.bat",
    "dev": "webpack serve"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.15.5",
    "@babel/preset-env": "^7.15.6",
    "babel-loader": "^8.2.2",
    "clean-webpack-plugin": "^4.0.0",
    "html-webpack-plugin": "^5.3.2",
    "webpack": "^5.56.1",
    "webpack-cli": "^4.8.0",
    "webpack-dev-server": "^4.3.1"
  }
}

程式說明

devDependencies:內容是 npm 安裝的外掛工具及版本,功能有:

  • webpack 本體
  • webpack 外掛 -- 清理編譯後檔案
  • webpack 外掛 -- 頁面樣板設定
  • babel -- 轉譯 ES6 程式碼為其它瀏覽器可讀的程式碼內容

 

scripts:在命令提示字元視窗下執行 webpack 的指令,語法為【npm run [scripts]】,SET 是設定 Windows 的環境變數,可以依照【設計】或【產品】語法來改變 webpack 封裝設定。[&&] 是 Windows 執行多指令的節點,某些靜態檔案還可以透過批次檔進行複製。

npm 在對專案處理時都會以 package.json 作為進入點,比較要記的就是這兩個屬性。


webpack.config.js - part 1

程式說明

package.json 的 SET NODE_ENV,它會在 webpack.config.js 的 process.env 被 NPM 寫入環境變數。

先快速導覽大分類,之後再一個個進去看。

module.export 這是 webpack 專案檔的核心。

mode 設定【設計】或【產品】模式;entry 要被 webpack 編譯的 js 檔案。

output 指定原檔案編譯後存放的路徑和命名規則。

devServer 執行 webpack server 時 Web 伺服器的參數。

module webpack 在讀取 js 檔以外的檔案時,所需要的 loader

devtool 編譯時做對照檔 (map),使瀏覽器能夠讀出編譯和原始程式碼的對照檔

plugin webpack 外掛設定區。 

webpack.config.js - part 2


程式說明

entry 裡,要寫上所有需要編譯的 js 檔案,格式為【暱稱:路徑】,暱稱在之後的【plugins 》 HtmlWebpackPlugin】會用到。

output 裡,path 說明 entry 裡的檔案在編譯後所放置的路徑,以 package.json 所在路徑為 root,可以自由指定,filename 為了確保客戶端每次都會重新讀取,可以把檔案加入【hash】參數,編譯會將 hash 碼帶入檔名,如:maindesk.f22745d9520340e7af08.bundle.js。


webpack.config.js - part3


程式說明

plugin 裡面主要放 webpack 外掛模組,有:

CleanWebpackPlugin 是每次編譯當下都會將目的目錄內的檔案全數清空。

webpack.DefinePlugin 是定義編譯環境的全域變數,此例可以在網路中的任何 JS 寫上類似以下的內容:

可以在編譯時決定要執行哪部份的程式內容,好用!

HtmlWebpackPlugin 決定讀取的樣板和 HTML 檔案路徑,chunks 是決定要寫入 entry 的哪些檔案,以暱稱設定,設為空字串時則不匯入任何 js 檔;也可以使用 excludeChunks 決定不加入的 js 清單。


結論時間

學習 Webpack 的過程中不斷帶入以前寫 Delphi 專案檔的時光,所以還蠻能理解 Webpack 的各項設定的內容,都已經用編譯了,那就順便把 Delphi 的設定概念套入,Webpack 還有很多細節的設定,只要大方向有了,再往細節裡找的難度就會下降很多!

今日成就:把 jQuery 網站套入 Webpack 專案編譯成功!


和你分享 😉

2021/10/04

還在 React 從入門到放棄?告訴你選擇比努力還重要的事


看了許多 React 的入門資料,大多會是從 create-react-app 專案起始,用久了覺得很不舒服,原因如下:

NPM 編譯經常無預期出錯

NPM 編譯經常無預期出錯,學習 React 免不了會經過一連串試錯的過程,但往往會不經意的出現許多編譯錯誤,大多是 Webpack 出現問題,在無法排除錯誤的情形下,最常做的就是重建 node_modules 內容,次數一多,是在學習 React 還是背誦 NPM i 指令,我已經搞不清楚了。

在設計時期,前端和後端 API 網域位置不一致時

只要不是靜態網頁,在前端的開發過程一定會和後端 API 傳遞訊息,尤其是開發網頁應用程式更是必須。在設計時期,只要前端和後端的 Web 服務網域不同,就一定會遇上【跨來源資源共用(Cross-Origin Resource Sharing (CORS))】這個大魔王,後端就必須進行 CORS 設定,但實際上線又會是在同一網域,只有開發時期才會遇到的 CORS 顯得是非常詭異的歷程。

你以為要從 JavaScript ES6 開始學習,事實上