【实践思考】动态切换项目资源的公共路径

开篇词

今年开始厚着脸皮写一些技术文章,大概平均两周能写出一篇。产量不高,一是由于平时上班仍是挺忙的,二是不想为了写而写,若是本身都不以为有意思的东西,是很难写下去的。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地址

结语

其实后来有一个思考,在“刀耕火种”的前端开发时期,咱们对整个项目是彻底掌控的,像上面这事反而简单。后来前端项目逐渐工程化自动化,给咱们带来便利的同时,一些特殊的,个性的需求却每每花费更多时间和心思去处理。就好像原来纯手工作的东西,想改些东西就改了,可是如今都用机器模具生产了,改东西的话要从模具和机器入手,可能会更麻烦。这让我想到了前段时间看的一个美剧《炸弹客》里面反派的观点,工业和科技带给咱们的到底是进步仍是束缚,有兴趣的能够看看那个美剧。

固然这只是闲来没事和你们吹吹水,我并不反对前端的工程化和自动化,相反还很享受其带来的好处。

最后再次强调一下,这不是一个完整的教程并推荐你们这样去作,而只是提出一个思路,里面用到的一些解决方式你们能够参考,但组合起来不保证是最优的方案,本文甚至本系列文章的目的更多的是但愿引起你们思考和讨论交流。纸上得来终觉浅,绝知此事要躬行,工做实践才是检验技术的最佳途径,但愿这篇文章可以你们带来帮助。

相关文章
相关标签/搜索