从源码分析url-loader 和 file-loader

url-loader

使用过webpack的开发者,基本上都听说或者用过url-loader。

a loader for webpack which transforms files into base64 URIs.

url-loader 允许你有条件地将文件转换为内联的 base64(减少小文件的 HTTP 请求数),如果文件大于该阈值,会自动的交给 file-loader 处理。

配置项

在看源码前,我个人比较倾向于先仔细阅读使用文档,文档的使用和配置往往可以帮助理解源码阅读。

image.png

options总共有6个配置属性,一般使用都只是配置limit:

  • limit: 小于limit值会被url-loader转换,默认是base64
  • mimetype: 被转换文件的mimetype,默认取文件扩展名
  • encoding: 默认base64
  • generator:默认转成base64:xxxx,开发者可以通过generator自己实现转换
  • fallback: 如果文件大于limit,把文件交给fallback 这个loader,默认是file-loader
  • esModule:是否es module

源码分析

如果只看url-loader的核心代码(loader函数),代码只有60行左右。
其中的 export const raw = true 是告诉 webpack 该 loader 获取的 content 是 buffer 类型
( loader 第一个值的类型是 JavaScript 代码字符串或者 buffer,开发者自行决定使用哪种类型)

export default function loader(content) {
  // Loader Options
  const options = getOptions(this) || {};

  validate(schema, options, {
    name: 'URL Loader',
    baseDataPath: 'options',
  });

  // No limit or within the specified limit
  if (shouldTransform(options.limit, content.length)) {
    const { resourcePath } = this;
    const mimetype = getMimetype(options.mimetype, resourcePath);
    const encoding = getEncoding(options.encoding);

    const encodedData = getEncodedData(
      options.generator,
      mimetype,
      encoding,
      content,
      resourcePath
    );

    const esModule =
      typeof options.esModule !== 'undefined' ? options.esModule : true;

    return `${
      esModule ? 'export default' : 'module.exports ='
    } ${JSON.stringify(encodedData)}`;
  }

  // Normalize the fallback.
  const {
    loader: fallbackLoader,
    options: fallbackOptions,
  } = normalizeFallback(options.fallback, options);

  // Require the fallback.
  const fallback = require(fallbackLoader);

  // Call the fallback, passing a copy of the loader context. The copy has the query replaced. This way, the fallback
  // loader receives the query which was intended for it instead of the query which was intended for url-loader.
  const fallbackLoaderContext = Object.assign({}, this, {
    query: fallbackOptions,
  });

  return fallback.call(fallbackLoaderContext, content);
}

// Loader Mode
export const raw = true;

获取options以及校验options

webpack官方文档中的 编写一个loader 一文介绍了编写loader需要使用到的两个工具库:loader-utilsschema-utils

充分利用 loader-utils 包。它提供了许多有用的工具,但最常用的一种工具是获取传递给 loader 的选项。schema-utils 包配合 loader-utils,用于保证 loader 选项,进行与 JSON Schema 结构一致的校验。

  const options = getOptions(this) || {};

  validate(schema, options, {
    name: 'URL Loader',
    baseDataPath: 'options',
  });

validate 需要一个 schema 来对获取到的 options 进行校验,url-loader 的 schema 如下图:

image.png

判断文件和limit的大小关系

content 是 Buffer 类型,buf.length 返回 buf 中的字节数,单位和 limit 一致。

  if (shouldTransform(options.limit, content.length)) {
    // .... 
  }

shouldTransform 中,对 limit 执行3种类型判断 :

  1. 如果是 true 或者 false,直接 return limit
  2. 如果是字符串,转成数字,再和 limit比较
  3. 如果是数字,直接和 limit 比较
  4. 不符合上述3种判断,return true
function shouldTransform(limit, size) {
  if (typeof limit === 'boolean') {
    return limit;
  }

  if (typeof limit === 'string') {
    return size <= parseInt(limit, 10);
  }

  if (typeof limit === 'number') {
    return size <= limit;
  }

  return true;
}

转化文件

shouldTransform 返回 true后,需要把当前文件转化成Data URLs (默认base64):

    const { resourcePath } = this;
    const mimetype = getMimetype(options.mimetype, resourcePath);
    const encoding = getEncoding(options.encoding);
    
    const encodedData = getEncodedData(
      options.generator,
      mimetype,
      encoding,
      content,
      resourcePath
    );

    const esModule =
      typeof options.esModule !== 'undefined' ? options.esModule : true;

    return `${
      esModule ? 'export default' : 'module.exports ='
    } ${JSON.stringify(encodedData)}`;

其中 this.resourcePath 是文件的绝对路径,通过 getMimetype 得到文件最终生成的 mimeType ,通过 getEncoding 得到编码方式。

mimetype 首先通过 path.extname 获取当前扩展名,再通过 mime-types 把扩展名包装成对应的mimeType

function getMimetype(mimetype, resourcePath) {
  if (typeof mimetype === 'boolean') {
    if (mimetype) {
      const resolvedMimeType = mime.contentType(path.extname(resourcePath));
      if (!resolvedMimeType) {
        return '';
      }

      return resolvedMimeType.replace(/;\s+charset/i, ';charset');
    }
    return '';
  }

  if (typeof mimetype === 'string') {
    return mimetype;
  }

  const resolvedMimeType = mime.contentType(path.extname(resourcePath));

  if (!resolvedMimeType) {
    return '';
  }

  return resolvedMimeType.replace(/;\s+charset/i, ';charset');
}

那下面这段代码有什么用处呢?

resolvedMimeType.replace(/;\s+charset/i, ';charset')

原因是 mime-typescontentType 返回值 ; charset 中间是有空格的:

image.png

而在Data URL 的书写格式中,mediatype ;charset 是无空格的:

image.png

因此需要做一步替换去除空格。

如上文配置项部分所说,encoding 默认是base64,开发者可以自行决定是否覆盖:

 function getEncoding(encoding) {
  if (typeof encoding === 'boolean') {
    return encoding ? 'base64' : '';
  }

  if (typeof encoding === 'string') {
    return encoding;
  }

  return 'base64';
}

最后,根据 getMimeTypegetEncoding 的返回值,对文件内容进行拼接转化。
如果存在 options.generator,则执行 options.generator 对内容做转化。
如果不存在,则按照 Data URL 格式拼接文件:

 data:[<mediatype>][;base64],<data>
function getEncodedData(generator, mimetype, encoding, content, resourcePath) {
  if (generator) {
    return generator(content, mimetype, encoding, resourcePath);
  }

  return `data:${mimetype}${encoding ? `;${encoding}` : ''},${content.toString(
    encoding || undefined
  )}`;
}

对于不符合shouldTransform的文件,则继续往下执行。

执行fallback loader

首先获取 fallback loader 和 options :

 const {
    loader: fallbackLoader,
    options: fallbackOptions,
  } = normalizeFallback(options.fallback, options);

url-loader 默认使用 file-loader 作为处理 >limit 文件 的 loader。

  • 如果开发者没有配置 options.fallback,就直接使用 url-loader 的 options 作为 file-loader的options。
  • 如果开发者配置了options.fallback
    • 如果 fallback 类型是 string,loader 名称和 options 通过?隔开
    • 如果 fallback 是 object,loader 名称和 options 分别为 fallback 的属性(这种写法在 url-loader 的文档没有介绍)

normalizeFallback如下:

export default function normalizeFallback(fallback, originalOptions) {
  let loader = 'file-loader';
  let options = {};

  if (typeof fallback === 'string') {
    loader = fallback;

    const index = fallback.indexOf('?');

    if (index >= 0) {
      loader = fallback.substr(0, index);
      options = loaderUtils.parseQuery(fallback.substr(index));
    }
  }

  if (fallback !== null && typeof fallback === 'object') {
    ({ loader, options } = fallback);
  }

  options = Object.assign({}, originalOptions, options);

  delete options.fallback;

  return { loader, options };
}

引入 loader,然后执行 loader 并返回结果:

  // Require the fallback.
  const fallback = require(fallbackLoader);

  // Call the fallback, passing a copy of the loader context. The copy has the query replaced. This way, the fallback
  // loader receives the query which was intended for it instead of the query which was intended for url-loader.
  const fallbackLoaderContext = Object.assign({}, this, {
    query: fallbackOptions,
  });

  return fallback.call(fallbackLoaderContext, content);

流程图

image.png

file-loader

Instructs webpack to emit the required object as file and to return its public URL

file-loader 可以指定要放置资源文件的位置,以及如何使用哈希等命名以获得更好的缓存。这意味着可以通过工程化方式就近管理项目中的图片文件,不用担心部署时 URL 的问题。

配置项

image.png

源码分析

获取options以及根据schema校验options:

 const options = getOptions(this);

  validate(schema, options, {
    name: 'File Loader',
    baseDataPath: 'options',
  });

获取 context 及生成文件名称。使用loader-utils提供的interpolateName获取文件的hash值,并生成唯一的文件名:

  const context = options.context || this.rootContext;
  const name = options.name || '[contenthash].[ext]';

  const url = interpolateName(this, name, {
    context,
    content,
    regExp: options.regExp,
  });

其中 interpolateName 的定义如下:

interpolatename(loadercontext, name, options)
//  loadercontext 为 loader 的上下文对象,name 为文件名称模板,options 为配置对象

根据配置的 outputPath 和 publicPath 生成最终的 outputPath 和 publicPath,如果想要在dev和prod环境写不同的值,就可以把outputPath和publicPath写成函数形式:

// 处理outputPath
  let outputPath = url;

  if (options.outputPath) {
    if (typeof options.outputPath === 'function') {
      outputPath = options.outputPath(url, this.resourcePath, context);
    } else {
      outputPath = path.posix.join(options.outputPath, url);
    }
  }

  // 处理publicPath
  let publicPath = `__webpack_public_path__ + ${JSON.stringify(outputPath)}`;

  if (options.publicPath) {
    if (typeof options.publicPath === 'function') {
      publicPath = options.publicPath(url, this.resourcePath, context);
    } else {
      publicPath = `${
        options.publicPath.endsWith('/')
          ? options.publicPath
          : `${options.publicPath}/`
      }${url}`;
    }

    publicPath = JSON.stringify(publicPath);
  }

  if (options.postTransformPublicPath) {
    publicPath = options.postTransformPublicPath(publicPath);
  }

处理emitFile。如果没有配置emitFile或者配置了emitFile,最后会执行this.emitFile在outputPath生成一个文件:

  if (typeof options.emitFile === 'undefined' || options.emitFile) {
    const assetInfo = {};

    if (typeof name === 'string') {
      let normalizedName = name;

      const idx = normalizedName.indexOf('?');

      if (idx >= 0) {
        normalizedName = normalizedName.substr(0, idx);
      }

      const isImmutable = /\[([^:\]]+:)?(hash|contenthash)(:[^\]]+)?]/gi.test(
        normalizedName
      ); 

      if (isImmutable === true) {
       //  指定 asset 是否可以长期缓存的标志位(包括哈希值)
        assetInfo.immutable = true;
      }
    }

    assetInfo.sourceFilename = normalizePath(
      path.relative(this.rootContext, this.resourcePath)
    );
   
    this.emitFile(outputPath, content, null, assetInfo);
  }

webpack官方文档里emitFile只有3个参数:

emitFile(name: string, content: Buffer|string, sourceMap: {...})

关于emitFile第四个参数是在file-loader里的issue上看到的。

导出最终路径:

  const esModule =
    typeof options.esModule !== 'undefined' ? options.esModule : true;

  return `${esModule ? 'export default' : 'module.exports ='} ${publicPath};`;

总结

file-loader可能会生成文件(emitFile),返回的是文件的路径;
url-loader 小于limit的文件返回的是Data URL字符串,其他文件返回执行 fallback loader 的结果;

url-loaderfile-loader 的唯一联系就是 url-loaderfile-loader 作为默认的 fallback loader,而我们也经常在项目的 url-loader 配置写 file-loader 的配置:

{
  limit: CDN_THRESHOLD,
  publicPath: `http://${genIp()}:9081/`,
  name: '[path]/[name].[ext]',
}

其中的 publicPathname 都属于 file-loader 的 options。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,219评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,363评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,933评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,020评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,400评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,640评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,896评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,597评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,327评论 1 244
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,581评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,072评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,399评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,054评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,849评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,672评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,585评论 2 270

推荐阅读更多精彩内容