Javascript Import maps

作者: yozosann | 来源:发表于2019-09-15 11:27 被阅读0次

    最新在学习jspm,里面出现了很多关于import maps的概念,想去学习下,鉴于中文文章基本上没有找到关于import maps的介绍,于是翻译了import-maps

    如有不正确欢迎指出。

    什么是Import maps?

    这个提案允许控制 js的 import语句或者import()表达式获取的库的url,并允许在非导入上下文中重用这个映射。这就解决了非常多的问题,比如:

    • 允许直接import标志符,就能在浏览器中运行,比如:import moment from "moment
    • 提供兜底解决方案,比如import $ from "jquery",他先会去尝试去CDN引用这个库,如果CDN挂了可以回退到引用本地版本。
    • 开启对一些内置模块或者其他功能的polyfill。
    • 共享import标识符在Javascript importing 上下文或者传统的url上下文,比如fetch()<img src="">或者<link href="">

    他的主要机制是通过导入import map(模块和对应url的映射),然后我们就可以在HTML或者CSS中接受使用url导入模块的上下文替换成import: URL scheme来导入模块。

    来看下面这个例子:

    import moment from "moment";
    import { partition } from "lodash";
    

    这样写纯粹的标识符会抛出错误,见explicitly reserved。(简单来说只能允许/ ./ ../开头的标识符)。
    但是如果有了import map:

    <script type="importmap">
    {
      "imports": {
        "moment": "/node_modules/moment/src/moment.js",
        "lodash": "/node_modules/lodash-es/lodash.js"
      }
    }
    </script>
    

    那种纯粹的写法就能被解析为:

    import moment from "/node_modules/moment/src/moment.js";
    import { partition } from "/node_modules/lodash-es/lodash.js";
    

    import:URL Schema的场景下:

    <link rel="modulepreload" href="import:lodash">
    

    更多关于值为"importmap"的script标签见: installation section.

    具体功能

    模块标识符映射

    模块的纯粹标识符

    {
      "imports": {
        "moment": "/node_modules/moment/src/moment.js",
        "lodash": "/node_modules/lodash-es/lodash.js"
      }
    }
    

    在设置了这个import map之后,

    import moment from "moment";
    import("lodash").then(_ => ...);
    

    以上这种写法就能在js里直接支持。

    需要注意的是映射的value,必须以/./或者../开头,或者是一个能够识别的url。在这个示例中的是一个类相对路径的地址,它会根据import map的基本路径进行解析,比如:内联的import map,它的基础路径就是页面url,而如果是外部资源的import map(<script type="importmap" src="xxxxx" />)那么它的基础路径,就是这个script标签的url。

    package的斜杠语法

    在nodejs或者打包环境中,我们经常会写这样的语句import localeData from "moment/locale/zh-cn.js"; ,他并非一个纯粹的库名,而是前缀固定,然后相对寻址去拿对应文件。而之前,我们都是说的支持纯粹的库名来map一个url,其实这样的写法也是可以支持的,只需要配置importmap:

    {
      "imports": {
        "moment": "/node_modules/moment/src/moment.js",
        "moment/": "/node_modules/moment/src/",
        "lodash": "/node_modules/lodash-es/lodash.js",
        "lodash/": "/node_modules/lodash-es/"
      }
    }
    

    import map通过识别以/结尾的的key值,来完成这样的功能,于是以下的写法都能支持:

    import localeData from "moment/locale/zh-cn.js";
    import fp from "lodash/fp.js";
    

    类URL的重映射

    Import maps特别允许类URL标识符的重新映射,它的其中一个优势是做兜底url映射。旦我们先展示一些基础用法来说明重映射这个概念:

    {
      "imports": {
        "https://www.unpkg.com/vue/dist/vue.runtime.esm.js": "/node_modules/vue/dist/vue.runtime.esm.js"
      }
    }
    

    可以用本地版本vue来替代全局任何从cdn获取的vue。

    {
      "imports": {
        "/app/helpers.mjs": "/app/helpers/index.mjs"
      }
    }
    

    这个重映射可以解析任何到 /app/helpers.mjs的路径,比如,在/app/路径下 import "./helpers.mjs" 或者 在 /app/models/app/helpers/index.mjs。 最终都能指向"/app/helpers/index.mjs"。在实际代码中,这样做可能不太好,容易产生一些混淆,不过这也足以证明import map的能力。

    Remapping也可以像上面讲的那样做前缀匹配,通过以/结尾:

    {
      "imports": {
        "https://www.unpkg.com/vue/": "/node_modules/vue/"
      }
    }
    

    这里主要表达对类url的解析和对纯粹标识符的解析是一样的。之前的例子是映射import "lodash"里的lodash,而这里变成了映射import "/app/helpers.mjs" 里的 /app/helpers.mjs。

    无扩展名imports

    在nodejs或者打包环境中,我们也经常import某个文件,省略其扩展名,import map没有这样高级的功能去一直寻址扩展名,但是我们能够直接配置缺失的扩展名在import map上。

     {
       "imports": {
         "lodash": "/node_modules/lodash-es/lodash.js",
         "lodash/": "/node_modules/lodash-es/",
         "lodash/fp": "/node_modules/lodash-es/fp.js",
       }
     }
    

    这样不仅我们能够支持import fp from "lodash/fp.js" 而且还能支持import fp from "loadsh/fp"

    尽管这个例子展示了如何允许使用import map实现无扩展的导入,但并不一定需要这样做。这样做会使import map臃肿,并使包的接口对人对工具都变得不是那么简单。

    这种臃肿是非常有问题的,你必须为每一个文件都配置映射,它无法匹配整个入口问。比如:import "./fp" 你就必须写 /node_modules/lodash-es/lodash.js/node_modules/lodash-es/fp 你就必须写/node_modules/lodash-es/fp.js。。想象这样映射下去,非常臃肿,所以建议如果能不用缺失扩展名的写法就不要用,这样会使整个系统更加简单。

    映射脚本中的hash

    脚本文件名中通常包含hash值,为了改善web的缓存能力。详见:this general discussion of the technique, 或者 this more JavaScript- and webpack-focused discussion

    在模块依赖的图中,这会存在一个问题:

    • 考虑一种没有hash值场景,app.mjs依赖dep.mjs,dep.mjs依赖 sub-dep.mjs。当我们改了sub-dep.mjs,app.mjs和dep.mjs依然缓存着的,我们只需要替换sub-dep.mjs即可。
    • 但是如果加了hash值,例如:app-8e0d62a03.mjs, dep-16f9d819a.mjs, 和 sub-dep-7be2aa47f.mjs,当我们改了sub-dep.mjs之后它的hash值会发生变化(如果不知道为什么的先去了解下加hash的原因),由于dep.mjs依赖的sub-dep.mjs hash值发生了变化,那么dep要更替依赖,那么dep的hash值也会发生变化,以此类推app的hash值也会发生变化,,那么这样依赖浏览器的缓存效率全失。

    Import maps 提供了一个途径来解决这样的窘境,通过解耦import语句中模块的标识符,例如:

    {
      "imports": {
        "app.mjs": "app-8e0d62a03.mjs",
        "dep.mjs": "dep-16f9d819a.mjs",
        "sub-dep.mjs": "sub-dep-7be2aa47f.mjs"
      }
    }
    

    那我们就可以用 import "./sub-dep.mjs"替代import "./sub-dep-7be2aa47f.mjs",如果我们的sub-dep.mjs发生了变化,我们只需要更新我们的import maps:

    {
      "imports": {
        "app.mjs": "app-8e0d62a03.mjs",
        "dep.mjs": "dep-16f9d819a.mjs",
        "sub-dep.mjs": "sub-dep-5f47101dc.mjs"
      }
    }
    

    这样即使sub-dep.mjs更新了,但是dep.mjs里的import "sub-dep.mjs"这句话也不会发生变化,那么它仍然可以继续缓存在浏览器中,同样app.mjs也如此。

    兜底方案

    第三方模块

    这就是我们之前说的如果CDN挂了,回退到访问本地的库的例子。我们通常使用这个方案 terrible document.write()-using sync-script-loading hacks来解决这个问题。现在import maps提供了一种能力来控制模块的解析,这样做能更好。

    使用一个兜底数组来提供兜底链接:

    {
      "imports": {
        "jquery": [
          "https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js",
          "/node_modules/jquery/dist/jquery.js"
        ]
      }
    }
    

    先加载CDN的资源,如果挂了,就加载本地node_modules里的资源(回退策略只会生效一次,之后便会缓存所有的功能)。
    "jquery": "/node_modules/jquery/dist/jquery.js" 其实就是"jquery": ["/node_modules/jquery/dist/jquery.js"] 的语法糖。

    内建模块且浏览器支持import maps

    我们不仅可以对第三方模块做兜底,这样适用于内建模块:

    {
      "imports": {
        "std:kv-storage": [
          "std:kv-storage",
          "/node_modules/kvs-polyfill/index.mjs"
        ]
      }
    }
    

    它首先回去尝试使用浏览器的"std:kv-storage",但是如果浏览器并不支持这个功能,就可以加载它的polyfill - /node_modules/kvs-polyfill/index.mjs
    注意:std:在这里只是解说意图,这个提案是通用的,可以使用任何内置模块前缀。。

    内建模块,但浏览器不支持import maps

    如果浏览器不支持import maps,import { StorageArea } from "std:kv-storage" 总会执行失败。有什么写法即可以在老式浏览器生效,也可以在支持import maps的浏览器生效:

    import { StorageArea } from "/node_modules/kvs-polyfill/index.mjs";
    

    然后配置重映射到内建模块上:

    {
      "imports": {
        "/node_modules/kvs-polyfill/index.mjs": [
          "std:kv-storage",
          "/node_modules/kvs-polyfill/index.mjs"
        ]
      }
    }
    
    • 这样不支持import maps的浏览器,会使用polyfill。
    • 支持import maps,但不支持KV storage的浏览器,会映射到URL上,于是使用polyfill。
    • 都支持的浏览器,就会直接使用内建模块。

    但是这样的方式不能工作在<script>里:

    import "/node_modules/virtual-scroller-polyfill/index.mjs";
    

    这样写之前讲过了,是没有问题的。但是:

    <script type="module" src="/node_modules/virtual-scroller-polyfill/index.mjs"></script>
    

    这样就不能正常运行了,这样会无条件的加载polyfill,而不会使用内建模块。
    如果还想上面的功能正常进行,应该这么写:

    <script type="module" src="import:/node_modules/virtual-scroller-polyfill/index.mjs"></script>
    

    遗憾的是,这样的写法只能运行在支持import maps的浏览器中,但是在不支持import maps的浏览器中,我们只有这么写:

    <script type="module">import "/node_modules/virtual-scroller-polyfill/index.mjs";</script>
    

    这样就能像上述我们所说那种运行了。

    作用域

    相同的模块多版本

    这是一个常见的case,比如我们用了socksjs-client这个库,他依赖querystringify@1.0,但是我们想使用querystringify@2.0的功能。一种解决方案是我们为我们使用的querystringify改个名字,但这并不是我们想要的解决方案。
    Import maps 提供了scope 来解决这样的情况:

    {
      "imports": {
        "querystringify": "/node_modules/querystringify/index.js"
      },
      "scopes": {
        "/node_modules/socksjs-client/": {
          "querystringify": "/node_modules/socksjs-client/querystringify/index.js"
        }
      }
    }
    

    使用了这个import maps,任何以/node_modules/socksjs-client/开头的库都会去使用/node_modules/socksjs-client/querystringify/index.js,然而顶级的imports确保其他我们使用到的querystringify,都是"/node_modules/querystringify/index.js"这个版本。

    作用域继承

    作用域以一种简单的方式合并切覆盖例如:

    {
      "imports": {
        "a": "/a-1.mjs",
        "b": "/b-1.mjs",
        "c": "/c-1.mjs"
      },
      "scopes": {
        "/scope2/": {
          "a": "/a-2.mjs"
        },
        "/scope2/scope3/": {
          "b": "/b-3.mjs"
        }
      }
    }
    

    将会按以下的这种方式解析:

    Specifier Referrer Resulting URL
    a /scope1/foo.mjs /a-1.mjs
    b /scope1/foo.mjs /b-1.mjs
    c /scope1/foo.mjs /c-1.mjs
    a /scope2/foo.mjs /a-2.mjs
    b /scope2/foo.mjs /b-1.mjs
    c /scope2/foo.mjs /c-1.mjs
    a /scope2/scope3/foo.mjs /a-2.mjs
    b /scope2/scope3/foo.mjs /b-3.mjs
    c /scope2/scope3/foo.mjs /c-1.mjs

    虚拟化

    有能包装、扩展、删除内建模块的能力是非常重要的,以下举例import maps如何做到这一点。
    注意:这同样对第三方模块有效,只是以内建模块为例子

    删除内建模块

    尽管这是非常极端及很少使用的,但是依然可能出现需要拒绝对某个模块访问的情况,如果是全局模块,我们可以这么做:

    delete self.WebSocket;
    

    在import maps中你可以限制对一个内建模块的访问,通过设置其值为空数组:

    {
      "imports": {
        "std:kv-storage": []
      }
    }
    

    或者设置为null:

    {
      "imports": {
        "std:kv-storage": null
      }
    }
    

    这样在代码里:

    import { Storage } from "std:kv-storage"; // throws
    

    就会抛出错误。

    选择性拒绝

    可以使用scope的特性,选择性限制对某个内建模块的访问:

    {
      "imports": {
        "std:kv-storage": null
      },
      "scopes": {
        "/js/storage-code/": {
          "std:kv-storage": "std:kv-storage"
        }
      }
    }
    

    "/js/storage-code/"中就可以访问到内建模块,而在全局访问都会抛出错误。

    当然也可以让模块模块访问不到"std:kv-storage",而其他地方都能访问:

    {
      "scopes": {
        "/node_modules/untrusted-third-party/": {
          "std:kv-storage": null
        }
      }
    }
    

    封装内建模块

    有时候我们需要封装一下内建模块,这就需要,封装的这个模块能访问原生的内建模块,而其他地方得访问封装后的模块,可以像以下这么写:

    {
      "imports": {
        "std:kv-storage": "/js/als-wrapper.mjs"
      },
      "scopes": {
        "/js/als-wrapper.mjs": {
          "std:kv-storage": "std:kv-storage"
        }
      }
    }
    

    其他地方import "std:kv-storage"时都会访问到"/js/als-wrapper.mjs",而这个就是封装后的模块,但是在"/js/als-wrapper.mjs"这个文件自身内访问的是原生的模块:

    import instrument from "/js/utils/instrumenter.mjs";
    import { storage as orginalStorage, StorageArea as OriginalStorageArea } from "std:kv-storage";
    
    export const storage = instrument(originalStorage);
    export const StorageArea = instrument(OriginalStorageArea);
    

    扩展内建模块

    这个封装一个内建模块非常相似,比如我们需要在内建模块上再export一个class:SuperAwesomeStorageArea。我们只需要依然使用上例的import maps,然后修改"/js/als-wrapper.mjs"代码:

    export { storage, StorageArea } from "std:kv-storage";
    export class SuperAwesomeStorageArea { ... };
    

    如果我们是在想原声模块上添加方法,那么我们不需要import maps,而是直接引入polyfill即可,polyfill文件里给StorageArea.prototype添加方法。

    import: URLs

    作为import maps概念的补充,提供了import: URL scheme。 它使得在HTML、CSS或者一些其他接受URL地方来使用import map。

    一个widget库的例子

    这个库不仅仅包括js模块,还报错css主题和一些图片,你可以配置import map:

    {
      "imports": {
        "widget": "/node_modules/widget/index.mjs",
        "widget/": "/node_modules/widget/"
      }
    }
    

    然后就可以使用:

    <link rel="stylesheet" href="import:widget/themes/light.css">
    <script type="module" src="import:widget"></script>
    

    或者:

    .back-button {
      background: url('import:widget/assets/back.svg');
    }
    

    这使得所有web资源都可以通过库标识符来访问。

    数据文件的例子

    比如the timezone database这样的json文件:

    {
      "imports": {
        "tzdata": "/node_modules/tzdata/timezone-data.json"
      }
    }
    

    然后就可以这样访问:

    const data = await (await fetch('import:tzdata')).json();
    

    URL解析语义

    如何精确的来解析import:仍然是有些模糊的,特别是以下两种情况使用URL时:

    • 解析相对路径标识符,例如import:./foo
    • 在不同地方决定使用哪个作用域时
      第一种情况并不重要,因为相应的用例并不十分重要。但是第二种情况就会产生歧义,比如我们app用了v2的widget,但是有一个第三方库使用了v1的widget,我们就需要配置:
    
    {
      "imports": {
        "widget": "/node_modules/widget-v2/index.mjs",
        "widget/": "/node_modules/widget-v2/"
      },
      "scopes": {
        "/node_modules/gadget/": {
          "widget": "/node_modules/widget-v1/index.mjs",
          "widget/": "/node_modules/widget-v1/"
        }
      }
    }
    

    问题在于/node_modules/gadget/styles.css会怎么解析?

    .back-button {
      background: url(import:widget/back-button.svg);
    }
    

    这里其实和你预期时一样的,使用的是v1相应的url。
    当前我们关于import:所提案的url解析方案是使用请求所在的URL,意思是:

    • 默认的,使用页面的基础URL(获取客户端API所基于的URL)
    • 如果请求发生在css里,使用css文件的url( locationReferrer Policy
    • 如果请求发生在 HTML module里,使用模块的相对路径。

    但是默认这种选择就会出现问题,假如在/node_modules/gadget/index.mjs里:

    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = 'import:widget/themes/light.css';
    document.head.append(link);
    

    因为他最终会成为页面上的一个link元素然后引用import:widget/themes/light.css,而不是js代码里的引用,所以他最终会按照页面的URL去解析,故得到的是v2的版本。但实际上这是在/node_modules/gadget/index.mjs里的代码,我希望他获取的是v1。

    一个提案是使用import.meta.resolve()

    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = import.meta.resolve('widget/themes/light.css');
    document.head.append(link);
    

    由于兜底这个功能这也会变得有些复杂。讨论:#79

    前一个版本的提案是解析import:相对于,当前执行脚本,但是也会有问题,见:#75

    Import map 处理程序

    安装

    <script type="importmap">
    {
      "imports": { ... },
      "scopes": { ... }
    }
    </script>
    

    或者:

    <script type="importmap" src="import-map.importmap"></script>
    

    但是当用src时,http的response的MIME必须是application/importmap+json(为什么不用application/json这将会使内容安全协议
    失效),并且就像大多数cdn上的库,都是开启了跨域,且通常都是按UTF-8解析的。

    由于import maps影响着所有的引入,所以import maps必须在所有其他模块解析前加载成功。这就代表import maps会阻塞其他任何导入的加载。

    这就意味着我们强烈推荐使用内联的import maps,这会带来更好的性能,就类似内联样式一样。直到他们处理完前,他们都会一直阻塞浏览器的运行。如果非要使用外部资源,推荐使用HTTP/2 Push或者bundled HTTP exchanges这样的功能来缓和阻塞造成的影响。

    还有另外一种结果就是,如果在import或者import:之后才加载import maps,那么就会抛错。import maps将会被忽略,且script元素也会触发一个错误事件。

    一个页面允许多个import maps,它的加载规则和以下等效:

    const result = {
      imports: { ...a.imports, ...b.imports },
      scopes: { ...a.scopes, ...b.scopes }
    };
    

    the proto-spec有更多关于这个的介绍。

    动态生成import maps

    你可以在执行任何import之前,执行以下脚本,动态生成import maps

    <script>
    const im = document.createElement('script');
    im.type = 'importmap';
    im.textContent = JSON.stringify({
      imports: {
        'my-library': Math.random() > 0.5 ? '/my-awesome-library.mjs' : '/my-rad-library.mjs';
      }
    });
    document.currentScript.after(im);
    </script>
    
    <script type="module">
    import 'my-library'; // will fetch the randomly-chosen URL
    </script>
    

    也可以动态覆盖已经导入的import

    <script type="importmap">
    {
      "imports": {
        "lodash": "/lodash.mjs",
        "moment": "/moment.mjs"
      }
    }
    </script>
    
    <script>
    if (!someFeatureDetection()) {
      const im = document.createElement('script');
      im.type = 'importmap';
      im.textContent = '{ "imports": { "lodash": "/lodash-legacy-browsers.js" } }';
      document.currentScript.after(im);
    }
    </script>
    
    <script type="module">
    import _ from "lodash"; // will fetch the right URL for this browser
    </script>
    

    动态覆盖的script在第二个,因为如果已经有人使用到了import maps再覆盖,就没有用了。

    作用域

    Import maps是应用级的东西,类似service workers(更确切地说,他是每一个模块的映射,作用在不同的环境里)。所以它不应该被手动的组合,而是应该由控制整个app视角的人或工具生成。比如,一个库里包含import map就是没有意义的, 库应该通过标识符简单的引用,而让整个应用去决定到底映射哪一个url。

    也就是这样促使了它是以<script type="importmap">的方式存在。

    由于应用的import maps更改了每个模块所在模块映射中的解析算法,因此它们不受模块的源码是否最初是来自跨域URL的影响。如果你加载了一个来自CDN的模块通过纯粹的标示符,你需要提前知晓这个模块会给你整个应用的带来哪些其他的纯粹标识符,且把这些标识符包含在应用的import map里。也就是说,您需要知道应用程序的所有传递依赖项是什么。对于库来说,由作者来控制他们使用的包是哪一个版本非常重要。

    相关文章

      网友评论

        本文标题:Javascript Import maps

        本文链接:https://www.haomeiwen.com/subject/gepiyctx.html