【實踐思考】動態切換項目資源的公共路徑

開篇詞

今年開始厚着臉皮寫一些技術文章,大概平均兩週能寫出一篇。產量不高,一是由於平時上班仍是挺忙的,二是不想爲了寫而寫,若是本身都不以爲有意思的東西,是很難寫下去的。javascript

俗話說的好評論裝逼末尾要加後綴,文章牛逼標題要加前綴,因此一直也想寫個什麼系列,能夠給文章標題加個前綴。可是像什麼閉包,防抖等基礎概念之類的,不少書籍和文檔都寫得都很是好,我以爲我也寫不出什麼新花樣來。css

其實前端入行幾年後感受本身一個明顯的變化是,從菜鳥的時候不少東西不會作,最常擔憂的是東西作不出來,到如今東西都能作出來,無非就是不一樣技術的選擇組合,最常擔憂的是實現方式是否是最優的,業內廣泛的作法又是什麼。因此我更想寫一個記錄平時工做中解決某個問題或是實現某個功能的系列文章,給有相似功能開發需求的朋友提供個思路,同時也和你們一塊兒分享交流看有沒有更好的實現方案。html

需求說明

爲了提升用戶訪問體驗,公司的APP及內嵌H5頁面都使用了CDN加速,可是前幾天出了一些問題,由於CDN服務商二級節點服務器宕機,致使部分區域的移動用戶沒法正常訪問,因爲只是CDN服務器的問題,其實源站地址仍是能夠訪問的。後來又出了一次相似的事,上頭就要求咱們作一個CDN切換的功能,若是某些用戶經過CDN訪問出了問題,能夠切換其餘CDN或直接訪問源站。
對於咱們前端來講,若是html文件和引入的靜態資源放在一塊兒,這事其實挺簡單。可是問題在於咱們html在一個服務器上,而靜態資源在專門的OSS(Object Storage Service,對象存儲服務)上,這就有點麻煩了。前端

相關知識介紹

什麼是CDN

內容分發網絡(英語:Content Delivery Network或Content Distribution Network,縮寫:CDN)是指一種透過互聯網互相鏈接的計算機網絡系統,利用最靠近每位用戶的服務器,更快、更可靠地將音樂、圖片、影片、應用程序及其餘文件發送給用戶,來提供高性能、可擴展性及低成本的網絡內容傳遞給用戶。vue

上面的文字內容來自維基百科,可能有些書面,簡單來講是這樣一個過程:java

你有一個文件a.js放在杭州的一臺服務器上,而後你有了一個源站地址hangzhou.oss.com/a.js,若是你請求這個地址就是從源站拿到a.js文件,可是這個服務器江浙滬可能訪問很快,可是在平頂山(我老家,河南一個市)訪問就不那麼快了,因而我花點錢配置了CDN加速,給了我一個CDN加速地址cdn.oss.com/a.js,當我在平頂山訪問這個地址時,請求到離我最近的鄭州的一臺CDN服務器,發現這臺服務器上是有這個a.js的,也沒有過時(緩存命中),因而就能夠直接返回。若是這個a.js過時或是不存在,則會規劃出一條最優線路找到下一個存在a.js的CDN服務器或者直接回源站拿取並保存,下一次訪問就能夠直接返回資源。node

CDN服務阿里,騰訊,華爲大廠都有在作,還有一些好比網宿,又拍雲,七牛等,境外cdn加速聽說Akamai(阿卡邁)挺不錯。咱們公司用的是上面哪個爲了照顧一下他們面子,就不指名了,歡迎你們評論推薦更多優秀的CDN服務商。react

資源引用路徑

一般網頁中資源的引用路徑有兩種,相對路徑和絕對路徑。webpack

相對路徑引入的文件是這樣:ios

<!-- html -->
 <!-- 相對路徑以 ./或者直接路徑名開頭 -->
 <link href="./style.css" rel="stylesheet" /> 
複製代碼

相對路徑相對的是當前頁面的路徑,假設這個html的訪問地址是www.demo.com,路徑就是根路徑/,引入的css網絡請求地址就是www.demo.com/style.css.若是這個html的訪問地址是www.demo.com/login,路徑就是/login,則css網絡請求地址爲www.demo.com/login/style.css
相對路徑引入資源最後的網路請求能夠看作location.host + location.pathname + 文件路徑

絕對路徑引入的文件是這樣:

<!-- html -->
 <!-- 絕對路徑以一個/開頭,可能有見過//www.demo.com/a.js這種的,這是表示引入資源協議(protocol)和當前頁面一致 -->
 <link href="/style.css" rel="stylesheet" /> 
複製代碼

絕對路徑引入的資源跟當前頁面的路徑無關,就是從根路徑開始,無論你html的訪問地址是www.demo.com仍是www.demo.com/login,上面絕對路徑引入的css樣式文件的網絡請求都是www.demo.com/style.css
相對路徑引入資源最後的網路請求能夠看作location.host + 文件路徑

一般不建議網頁中使用相對路徑引入資源,尤爲是如今不少SPA應用前端控制路由改變路徑,相對路徑可能會形成不少混亂和麻煩。平時項目開發時能夠用相對路徑,而後用webpack這樣的打包工具經過配置公共路徑,打包時把相對路徑替換掉。vue和react官方腳手架建立的項目,webpack默認的公共路徑就是/,最後打包後的文件中全部的資源路徑都是以/開頭的絕對路徑。

公共路徑

webpack中publicPath就是用來配置公共路徑的,公共路徑是項目打包後資源的基礎路徑,也能夠理解爲前綴,一般狀況下都是/,表示當前訪問地址的絕對路徑。但有的時候,咱們會將js,css,圖片等靜態資源存放另外一臺服務器,這時就能夠將公共路徑設置爲對應的域名。好比咱們設置publicPath爲https://oss.demo.com,那麼像style.css的引用路徑就會是https://oss.demo.com/style.css這樣完整的網路地址。

如今想一下爲何前面說html文件和引入的靜態資源放在一塊兒切換CDN這事就簡單。很明顯,放在一塊兒使用絕對路徑,靜態資源是跟着訪問地址的。以cdn.demo.com訪問到html,html中的資源請求就都是cdn.demo.com開頭,你換oss.demo.com就是oss.demo.com,至關於自動切換,根本不用咱們作什麼。可是當咱們靜態資源和html在不一樣的服務器,引入路徑已經寫死了前綴地址,像上面的https://oss.demo.com/style.css,你無論是從任何地址訪問到html,這個css的請求永遠都是https://oss.demo.com/style.css

像create-react-app建立的項目,在未使用npm run eject彈出webpack配置文件以前,能夠經過建立或修改package.json中的homepage字段來修改公共路徑。

方案思路

1.APP代理請求

當時我首先想到的一個方案是APP代理請求,由於咱們的頁面是內嵌在APP裏面的,頁面的全部網絡請求APP都能攔截的到,切換CDN後APP將攔截到的全部網頁請求替換爲切換後的CDN地址,大概示意圖以下。

切換前:

切換後:

這個方案的好處是前端網頁不用作任何修改,之後若是再添加其它的CDN,也只須要APP端多配置幾個代理地址便可,客戶端評估了可行性後決定去作。可是他們提出攔截處理網頁的網絡請求,可能對APP的性能形成影響,但願咱們前端之後能本身實現這個切換。

我在提出這個方案以前特地去查了一下,安卓,蘋果和windows各端實現的難易程度是不等的,並且ios的WKWebView中攔截POST請求會致使body丟失,有解決方法但會有些麻煩。建議API調用地址切換前端本身控制是簡單的,APP只負責資源加載的代理攔截。

2.前端屢次構建

因而還要再想一個方案出來,也就是前端本身來切換,如今先來看一下咱們的項目狀況:

  • React單頁應用,經過webpack打包。
  • 線上訪問地址是https://www.demo.com
  • 網頁用到靜態資源服務器地址爲https://oss.demo.com
  • 靜態資源服務器CDN加速地址爲https://cdn.demo.com
  • 網頁中全部的靜態資源的公共路徑在構建時已經設置爲https://cdn.demo.com

一種簡單粗暴的方式是我構建兩次,一次公共路徑設置爲https://oss.demo.com,一次公共路徑設置爲https://cdn.demo.com,放在https://oss.demo.com不一樣路徑下。兩個html文件放在https://www.demo.com,經過監聽不一樣端口或區分一下參數響應不一樣的html就能夠了。

這種方法的好處是簡單,不用對項目進行什麼處理,缺點是整個項目要出兩套,之後添加一個cdn線路就要多加一套,並且須要中間層的配合,因此這個方案被我排除掉。

3.動態切換公共路徑

既然不想屢次構建,那麼要實現的就是,只有一個html,經過某種手段動態切換項目中的公共路徑,公共路徑改變就意味着訪問資源請求的改變,從而達到CDN切換的目的。

首先說動態,這個最簡單的方式就是經過URL傳參,不一樣的參數對應不一樣的地址。好比咱們設置一個cdn的參數,若是訪問地址是https://www.demo.com?cdn=1時咱們使用https://cdn.demo.com做爲公共路徑,https://www.demo.com?cdn=0時使用https://oss.demo.com做爲公共路徑。

比較難的是公共路徑的切換,上面已經說了公共路徑是webpack在構建打包時就已經經過配置寫入項目的,即在打包時就已經肯定了公共路徑的,查看打包後的js代碼會發現這樣的代碼。

// __webpack_public_path__
__webpack_require__.p = "https://oss.demo.com";

(function(module, exports, __webpack_require__) {
    eval("module.exports = __webpack_require__.p + \"a.f58ad020.jpg\";\n\n//# sourceURL=webpack:///./a.jpg?");
 }),
複製代碼

咱們能夠看到公共路徑賦值給了__webpack_require__.p,若是想要動態切換公共路徑,意味着咱們須要在後期修改__webpack_require__.p的值,webpack提供了一個特有變量__webpack_public_path__

webpack特有變量,就是webpack在打包咱們代碼時外面包裹了一層函數,一些變量經過參數傳遞進來讓咱們能夠在代碼中使用,即便這些變量在宿主環境(好比瀏覽器)裏面是沒有的(像require,import,export等)。

咱們只需給__webpack_public_path__賦值就能夠改變公共路徑,建議放在入口文件的最頂部,以下:

// publicConfig.js
__webpack_public_path__ = 'https://cdn.demo.com';

// 入口文件 index.js
import './publicConfig.js'   
import React from 'react';
import ReactDOM from 'react-dom';
複製代碼

這段代碼打包後會有這樣一段內容。

(function(module, exports, __webpack_require__) {
    eval("__webpack_require__.p = \"https://cdn.demo.com\";\r\n\n\n//# sourceURL=webpack:///./src/publicConfig.js?");
}),
複製代碼

毫無疑問__webpack_require__.p被從新賦值了,雖然__webpack_require__是做爲參數傳入的,可是因爲是引用類型,源對象上的p也發生了變化。

看似問題輕易獲得瞭解決,可是這種修改只針對js文件中的公共路徑,對html中的css和js文件地址不起做用,css樣式文件中經過url()方式引入的圖片也無效。

要說明的一點是,若是你並未像咱們的項目同樣將css樣式文件單獨分離出來,是css in js的形式,公共路徑的動態修改css樣式文件是生效的。

若是是create-react-app建立的項目,修改__webpack_public_path__可能會不生效,須要經過刪除publicPath配置項來解決(是不設置而不是設置爲空),可是致使的緣由尚未深刻的去分析。

最終方案

單純只經過修改webpack配置來一步到位解決咱們全部問題怕是有些困難,咱們可能要針對不一樣的文件進行不一樣操做。

  1. js和css:兩套,每套文件裏面的公共路徑是不一樣的。
  2. html:一個html文件,但因爲有兩套js和css,因此在html中就要能動態加載不一樣的js和css文件。
  3. 圖片,音視頻等資源:因爲使用的都是相同的資源,一套。

因爲webpack打包其實比較耗時(跟項目大小也有關係),因此但願只打包一次就完成上面全部步驟。

方案實現

第三條不用處理,正常打包就行,從第二條開始。

打包一次生成兩套公共路徑不一樣的js和css文件,我相信經過修改webpack配置,或者某個webpack的plugin能夠實現這個功能,可是這裏我決定使用最簡單粗暴的方式,文本替換。

咱們首先以https://oss.demo.com爲公共路徑打包出一份,而後複製js和css文件夾,對複製出的兩個文件夾內全部的文件進行文本搜索和替換,將https://oss.demo.com替換爲https://cdn.demo.com

咱們固然不能手動去作這些事,寫一個node腳本,建議把CDN相關信息配置到一個專門的json文件,方便管理和之後增添刪除,大概代碼以下。

{
  "cdnList": ["Cdn","Cdn2"],
  "cdnUrl": {
    "Default": "oss.demo.com",
    "Cdn": "cdn.demo.com",
    "Cdn2": "cdn2.demo.com",
  }
}
複製代碼
// scripts/replace.js
const path = require("path");
const fs = require("fs-extra"); //fs加強版,用了複製文件夾
const replace = require("replace-in-file"); //替換文件中的文本
const { cdnList, cdnUrl } = require("../project.json");

//建立一個替換任務 oss.demo.com -> cdn.demo.com
const createReplaceOptions = (dir, cdn) => {
  return {
    files: `${path.resolve(__dirname, `../build/static/${dir}${cdn}/`)}/*.*`,
    from: new RegExp(`${cdnUrl.Default}`, "g"),
    to: cdnUrl[cdn]
  };
};

//建立一個拷貝任務
const createCopy = (dir, cdn) => {
  return fs
    //文件夾拷貝 
    .copy(
      path.resolve(__dirname, `../build/static/${dir}`),
      path.resolve(__dirname, `../build/static/${dir}${cdn}`)
    )
    //對拷貝後文件夾中全部文件進行文本替換
    .then(() => {
      const options = createReplaceOptions(dir, cdn);
      return replace(options)
        .then(results => {
          console.log("替換結果:", results);
        })
    });
};
//根據cdn列表建立對應拷貝替換任務
cdnList.forEach(item => {
  const jsCopy = createCopy("js", item);
  const cssCopy = createCopy("css", item);
  Promise.all([jsCopy, cssCopy])
    .then(() => console.log("處理完成!"))
    .catch(err => console.error("處理失敗:",err));
});

複製代碼

在package.json裏面,對build命令進行修改,在執行完webpack打包後,執行復制替換操做。

{
  "scripts": {
    "build_cdn": "node scripts/build.js && node scripts/replace.js",
  }
}

複製代碼

Tips:用&&鏈接兩條命令,前面一條命令執行完纔會執行下一條,&則是前一條後臺執行同時並行執行後一條,因此使用時請留意命令執行順序的影響。

咱們看一下效果:

這一步解決了css中url()引入資源和js中直接經過網絡地址引入資源的公共路徑問題,接下來是動態引入對應的css和js文件,方法就是根據條件建立link標籤和script標籤,插入html中便可。

// cdn.js
var query = parseQueryString(window.location.href); //格式化url參數這裏就不寫詳細代碼了
var cdn = query.cdn; 
var cdnList = {
  Default: "https://oss.demo.com/",
  Cdn: "https://cdn.demo.com/",
  Cdn2: "https://cdn2.demo.com/"
};
//將判斷後的公共路徑存儲在window上,後面有用。
if (cdnList[cdn]) {
  window.publicPath = cdnList[cdn];
} else {
  cdn = "";
  window.publicPath = cdnList.Default;
}
//動態加載css和js
function asyncAppendNode(tagName, fileName) {
  //css,js文件地址
  function createUrl(type) {
    return window.publicPath + "static/" + type + cdn + "/" + fileName;
  }
  var node = document.createElement(tagName);
  if (tagName === "link") {
    node.type = "text/css";
    node.rel = "stylesheet";
    node.href = createUrl("css");
    document.head.appendChild(node);
  } else {
    node.src = createUrl("js");
    document.body.appendChild(node);
  }
}

複製代碼

咱們在html的head中引入這cdn.js文件(或直接寫在html中也能夠),確保其在網頁加載後優先執行。但這種引入方式會致使這個文件不走webpack打包,沒有babel編譯,全部爲了兼容更多瀏覽器建議不要使用太新的js特性。

接下來咱們來想一下本來webpack打包後js和css的引入方式,首先webpack打包會將css和js處理,生成文件名中帶有hash值(爲了控制版本)的打包後文件,而後經過html-webpack-plugin這個插件將打包後的文件引入到html中。

好比有兩個文件,a.css和b.js,打包後插入html會變成這樣。

<head>
  <!-- 這個文件是直接在html中添加的因此webpack沒有打包 -->
  <script src="/cdn.js"></script>
  <link href="https://oss.demo.com/a.388e587e.css" rel="stylesheet">
</head>
<body>
  <script src="https://oss.demo.com/b.6b602746.js"></script>
</body>
複製代碼

而咱們但願生成後的html是下面這個樣子。

<head>
  <script src="/cdn.js"></script>
  <script> asyncAppendNode("link","a.388e587e.css"); </script>
</head>
<body>
   <script> asyncAppendNode("script","b.6b602746.js"); </script>
</body>
複製代碼

因爲咱們已經在cdn.js寫好了動態加載的方法asyncAppendNode,這裏直接調用,傳入必要參數就能夠了,我能夠打包後手動修改,可是最好仍是打包後直接就是咱們想要的這種形式。

如今剩下最後一個問題,怎麼從原來的css,js文件引入方式改成函數調用,不用想只能經過html-webpack-plugin來作文章,可是隻是經過配置是知足不了咱們需求的,好在html-webpack-plugin爲咱們提供了插件擴展,咱們能夠爲html-webpack-plugin來編寫我們本身的自定義插件,來實現須要的功能。

const HtmlWebpackPlugin = require('html-webpack-plugin');

class DynamicLoadHtmlWebpackPlugin {
    constructor(options = {}) {
        // 配置插件用到的參數,callbackName就是動態加載函數的函數名
        // cdnVariableName就咱們上面講過的公共路徑存儲的變量名,咱們cdn.js中是存到了window.publicPath上。
        const { callbackName = 'callback', cdnVariableName } = options;
        this.callbackName = callbackName;
        this.cdnVariableName = cdnVariableName;
    }
    // 重寫html-webpack-plugin的生成數據
    rewriteData(node, data, fnName, publicPath) {
        //將插入css引用,改成插入函數調用的script。
        if (node === 'script') {
            const fileNames = data.map((item) =>
                item.attributes.href.split('/').pop(),
            );
            const styleHtml = fileNames
                .map((item) => `${fnName}('${node}','${item}');`)
                .join('');
            return [
                { tagName: 'script', voidTag: false, innerHTML: styleHtml },
            ];
        } else {
            //js插入有兩類,一類是js文件引用,咱們改成插入函數調用的script。還有一類是內聯script代碼,咱們不用改成插入函數調用的形式。可是create-react-app建立的項目,環境變量賦值__webpack_require__.p = xxx是寫在這裏的,咱們就處理一下,將公共路徑替換爲咱們傳入的變量名。
            const inlineScript = [];
            const srcScript = [];
            data.forEach((item) => {
                if (item.innerHTML) {
                    if (
                        typeof publicPath === 'string' &&
                        this.cdnVariableName
                    ) {
                        const html = item.innerHTML;
                        const newHtml = html.replace(
                            `="${publicPath}"`,
                            `=${this.cdnVariableName}`,
                        );
                        item.innerHTML = newHtml;
                    }
                    inlineScript.push(item);
                } else {
                    srcScript.push(item.attributes.src.split('/').pop());
                }
            });
            const scriptHtml = srcScript
                .map((item) => `${fnName}('${node}','${item}');`)
                .join('');
            return [
                ...inlineScript,
                { tagName: 'script', closeTag: true, innerHTML: scriptHtml },
            ];
        }
    }
    // HtmlWebpackPlugin在打包過程當中,不一樣生命週期的回調,詳細能夠參考官方文檔,不一樣的生命週期,數據的內容不一樣。
    apply(compiler) {
        compiler.hooks.compilation.tap(
            'DynamicLoadHtmlWebpackPlugin',
            (compilation) => {
                HtmlWebpackPlugin.getHooks(
                    compilation,
                ).beforeAssetTagGeneration.tapAsync(
                    (data, cb) => {
                        //在這個生命週期中能夠拿到webpack配置的publicPath,保存一下。
                        this.publicPath = data.assets.publicPath;
                        cb(null, data);
                    },
                );
                HtmlWebpackPlugin.getHooks(
                    compilation,
                ).afterTemplateExecution.tapAsync(
                    (data, cb) => {
                        //在這個生命週期中,js和css的文件名已經確認,要插入標籤的相關信息都放在一個數組對象中,很好處理,咱們對其進行重寫。
                        const newStyleData = this.rewriteData(
                            'link',
                            data.headTags,
                            this.callbackName,
                        );
                        data.headTags = newStyleData;
                        const newScriptData = this.rewriteData(
                            'script',
                            data.bodyTags,
                            this.callbackName,
                            this.publicPath,
                        );
                        data.bodyTags = newScriptData;
                        cb(null, data);
                    },
                );
            },
        );
    }
}

module.exports = DynamicLoadHtmlWebpackPlugin;

複製代碼

插件寫完,在webpack.config.js中引入使用就能夠了。

const HtmlWebpackPlugin = require("html-webpack-plugin");
const DynamicLoadHtmlWebpackPlugin = require("./dynamicLoadHtmlWebpackPlugin");
module.exports = {
  ...
  plugins:[
    new HtmlWebpackPlugin(),
    new DynamicLoadHtmlWebpackPlugin({
          callbackName: "asyncAppendNode",
          cdnVariableName: "window.publicPath"
    }),
  ]
  ...
}

複製代碼

而後看一下打包後的html。

效果展現

至此咱們動態切換項目公共路徑的功能已經開發完畢,咱們經過url參數的改變,能夠控制整個項目中資源的網絡前綴,最後咱們模擬看一下實際狀況,網站地址是localhost:3000,CDN地址分別是localhost:3001和localhost:3002,讓3001地址掛掉。

demo地址

結語

其實後來有一個思考,在「刀耕火種」的前端開發時期,咱們對整個項目是徹底掌控的,像上面這事反而簡單。後來前端項目逐漸工程化自動化,給咱們帶來便利的同時,一些特殊的,個性的需求卻每每花費更多時間和心思去處理。就好像原來純手工作的東西,想改些東西就改了,可是如今都用機器模具生產了,改東西的話要從模具和機器入手,可能會更麻煩。這讓我想到了前段時間看的一個美劇《炸彈客》裏面反派的觀點,工業和科技帶給咱們的到底是進步仍是束縛,有興趣的能夠看看那個美劇。

固然這只是閒來沒事和你們吹吹水,我並不反對前端的工程化和自動化,相反還很享受其帶來的好處。

最後再次強調一下,這不是一個完整的教程並推薦你們這樣去作,而只是提出一個思路,裏面用到的一些解決方式你們能夠參考,但組合起來不保證是最優的方案,本文甚至本系列文章的目的更多的是但願引起你們思考和討論交流。紙上得來終覺淺,絕知此事要躬行,工做實踐纔是檢驗技術的最佳途徑,但願這篇文章可以你們帶來幫助。

相關文章
相關標籤/搜索