rollup和vue都在用的magic-string是个什么东西

6,495 阅读27分钟

magic-string是一个用于处理字符串的JavaScript库。它可以让你在字符串中进行插入、删除、替换等操作,并且能够生成准确的sourcemap。

这个库特别适用于需要对源代码进行轻微修改并保存sourcemap的情况,比如替换字符、添加内容等操作。通过 magic-string,你可以确保在字符串操作的同时,sourcemap能够保持准确,不会因为操作而失真。

在Vue3中,magic-string被用于宏函数的解析。

比如在defineModel的实现中,使用了以下的方法

  ctx.s.overwrite(
    ctx.startOffset! + node.start!,
    ctx.startOffset! + node.end!,
    `${ctx.helper('useModel')}(__props, ${JSON.stringify(modelName)}${
      runtimeOptions ? `, ${runtimeOptions}` : ``
    })`
  )

s正是magic-string对源码的实例对象,而overwrite是magic-string的方法,表示将defineModel重写为useModel,并传入modelName,如果有options就拼上options。

在Vite中,magic-string不光处理插件中的代码重写,还会构建sourcemap,供开发者工具识别并调试。

const urlWithoutTimestamp = removeTimestampQuery(req.url!)
const ms = new MagicString(code)
content = getCodeWithSourcemap(
  type,
  code,
  ms.generateMap({
    source: path.basename(urlWithoutTimestamp),
    hires: "boundary",
    includeContent: true,
  })
)

这里是Vite的server的代码,如果传入的是js类型,如果没有检测到sourcemap,它会生成一个新的sourcemap。

在Rollup中,magic-string被用作实现Tree shaking的工具。值得注意的是,Rollup内部处理源码时,并非直接操作实际的字符串,而是使用magic-string的实例来表示源码。

export function treeshakeNode(node: Node, code: MagicString, start: number, end: number): void {
	code.remove(start, end);
	node.removeAnnotations(code);
}

那么我们来看一下他的基本使用方法。

使用

import MagicString from 'magic-string';
import fs from 'fs';

const s = new MagicString('myName = lumozx');

s.update(0, 6, 'thisIsMyName');
s.toString(); // 'thisIsMyName = lumozx'

s.update(9, 15, 'lin');
s.toString(); // 'thisIsMyName = lin'

s.prepend('const ').append(';');
s.toString(); // 'const thisIsMyName = lin;'

s.update(9, 15, 'alice');
s.toString(); // 'const thisIsMyName = alice;'

const map = s.generateMap({
	source: 'source.js',
	file: 'converted.js.map',
	includeContent: true,
}); // generates a v3 sourcemap

fs.writeFileSync('converted.js', s.toString());
fs.writeFileSync('converted.js.map', map.toString());

从使用例子上来看,可以得出以下几个信息:

  • 区间选择遵循"左闭右开"的概念,也就是选择范围包含左边的值,但不包括右边的值。
  • 支持链式调用。
  • 区间会自动加入offset计算,比如已经使用了prepend和append给开头和结尾加入了多个字符,之后使用update变更字符串,依然可以使用原始区间,这样省略了自己去计算offset的步骤。
  • 可以生成v3版本的sourcemap。

那么,他是怎么处理的呢?我们直接看一看源代码。

初始化

首先我们需要使用new MagicString,来实例一个MagicString对象,所以我们constructor就是一个初始化的过程,我们直接看一看constructor做了什么。

constructor(string, options = {}) {
  // 创建一个初始的 Chunk 实例,表示整个源代码字符串
  const chunk = new Chunk(0, string.length, string);

  // 定义 MagicString 实例的属性
  Object.defineProperties(this, {
    original: { writable: true, value: string },  // 原始字符串
    outro: { writable: true, value: '' },  // 结束字符串(将在原始字符串后追加的内容)
    intro: { writable: true, value: '' },  // 开始字符串(将在原始字符串前追加的内容)
    firstChunk: { writable: true, value: chunk },  // 第一个 Chunk 实例
    lastChunk: { writable: true, value: chunk },  // 最后一个 Chunk 实例
    lastSearchedChunk: { writable: true, value: chunk },  // 最后搜索的 Chunk 实例
    byStart: { writable: true, value: {} },  // 根据起始位置索引的 Chunk 实例的映射
    byEnd: { writable: true, value: {} },  // 根据结束位置索引的 Chunk 实例的映射
    filename: { writable: true, value: options.filename },  // 文件名(如果有)
    indentExclusionRanges: { writable: true, value: options.indentExclusionRanges },  // 缩进排除范围
    sourcemapLocations: { writable: true, value: new BitSet() },  // sourcemap的位置信息
    storedNames: { writable: true, value: {} },  // 存储的名称
    indentStr: { writable: true, value: undefined },  // 缩进字符串
  });

  this.byStart[0] = chunk;  // 将第一个 Chunk 实例添加到起始位置索引映射中
  this.byEnd[string.length] = chunk;  // 将第一个 Chunk 实例添加到结束位置索引映射中
}

在这个构造函数中,MagicString实例被初始化为一个包含初始源代码字符串的Chunk实例。

构造函数允许在初始化时指定源代码字符串以及一些可选的配置选项。

那么Chunk的构造函数是什么呢?

constructor(start, end, content) {
  this.start = start;  // 片段的起始位置
  this.end = end;  // 片段的结束位置
  this.original = content;  // 原始内容

  this.intro = '';  // 片段的前缀
  this.outro = '';  // 片段的后缀

  this.content = content;  // 当前片段的内容
  this.storeName = false;  // 是否存储名称
  this.edited = false;  // 是否被编辑过

  this.previous = null;  // 上一个片段
  this.next = null;  // 下一个片段
}

我们看到,Chunk构造函数将初始值保存在自己的属性中,start默认是0,end默认是字符串长度,等于字符串结尾开区间的索引,content就是字符串本身。

看起来初始化就是记录了传入的代码(字符串)的初始数据,似乎没有什么特别的。那么他的那些方法是怎么实现的呢?

常用方法

update

我们先看一下update

update(start, end, content, options) {
  // 检查替换内容是否为字符串
  
  // 处理负数的起始位置和结束位置
  while (start < 0) start += this.original.length;
  while (end < 0) end += this.original.length;

  // 检查结束位置是否超出范围

  // 检查替换范围是否为零长度

  // 将替换范围拆分为多个片段
  this._split(start);
  this._split(end);

  // 处理选项参数
  if (options === true) {
    // 如果选项为true,则为storeName参数
      warned.storeName = true;
    }

    options = { storeName: true };
  }

  // 获取storeName和overwrite选项
  const storeName = options !== undefined ? options.storeName : false;
  const overwrite = options !== undefined ? options.overwrite : false;

  // 如果需要存储名称,将原始内容存储到storedNames中
  if (storeName) {
    const original = this.original.slice(start, end);
    Object.defineProperty(this.storedNames, original, {
      writable: true,
      value: true,
      enumerable: true,
    });
  }
// 省略
}

update 方法接受四个参数:start 表示起始索引,end 表示结束索引,content 表示替换的内容,options 表示选项参数,用于控制替换行为。

方法首先处理负数的起始索引和结束索引,并检查结束索引是否超出字符串范围。我们可以看到,它使用的是original来比较字符串的范围,在解析上文初始化的代码的时候,我们已经知道,original是使用Object.defineProperties来赋值的,而值就是MagicString构造函数的入参。

也就是说,original就是原始字符串的备份,所以无论如何变换,基准范围就是原始字符串的范围。

然后,它使用_split将替换范围拆分成多个 Chunk 实例。

然后根据 options 参数进行替换、追加或插入操作。

在替换操作中,update 方法会首先查找起始索引和结束索引之间的所有 Chunk 实例,然后在这些 Chunk 实例上进行相应的操作,保持源代码的结构和顺序。这部分代码先省略。

我们_split看看是怎么做的。

_split(index) {
  // 如果指定索引位置的起始或结束处已经存在 Chunk 实例,直接返回
  if (this.byStart[index] || this.byEnd[index]) return;

  // 初始化搜索方向和起始 Chunk 实例
  let chunk = this.lastSearchedChunk;
  const searchForward = index > chunk.end;

  // 在 Chunk 实例链表中查找包含指定索引位置的 Chunk 实例
  while (chunk) {
    // 如果当前 Chunk 实例包含指定索引位置,调用 _splitChunk 方法进行拆分
    if (chunk.contains(index)) return this._splitChunk(chunk, index);

    // 根据搜索方向更新当前 Chunk 实例
    chunk = searchForward ? this.byStart[chunk.end] : this.byEnd[chunk.start];
  }
}

// chunk.contains
contains(index) {
  return this.start < index && index < this.end;
}

_split接受一个参数 index,表示要在该索引位置处拆分 Chunk 实例。

首先检查在起始索引和结束索引位置处是否已经存在 Chunk 实例,如果存在则说明该位置已经拆分过,直接返回。也就是说,this.byEnd和this.byStart起到了缓存的作用。

在初始化的时候,会自动缓存索引为0的chunk,放到byStart中,同时会缓存索引为字符长度的chunk,放到byEnd中。所以如果开始或者结束索引符合上述两个条件,是直接返回的。

如果缓存中没有,方法从最后一次搜索到的 Chunk 实例开始,默认是初始化的chunk,根据指定的索引位置,向前或向后搜索,找到包含指定索引位置的 Chunk 实例。一旦找到,就调用 _splitChunk 方法进行拆分操作。

我们接着看看看_splitChunk 方法

_splitChunk(chunk, index) {
  // 如果已经编辑过的 chunk 不为空,且拆分的位置处有内容,抛出错误
  if (chunk.edited && chunk.content.length) {
      return
  }
  // 在指定索引位置拆分 chunk,并获取新的 chunk 实例
  const newChunk = chunk.split(index);

  // 更新索引,将原始 chunk 和新 chunk 实例添加到索引中
  this.byEnd[index] = chunk;
  this.byStart[index] = newChunk;
  this.byEnd[newChunk.end] = newChunk;

  // 如果拆分的是最后一个 chunk,更新 this.lastChunk 为新的 chunk 实例
  if (chunk === this.lastChunk) this.lastChunk = newChunk;

  // 更新最后搜索到的 chunk 实例为当前拆分的 chunk
  this.lastSearchedChunk = chunk;
  return true;
}

它接受两个参数:chunk 是要拆分的 Chunk 实例,index 是要在哪个位置拆分。首先检查要拆分的 chunk 是否已经编辑过(edited 不为空)并且拆分位置处没有内容(content.length 为空)。如果不满足这些条件,说明拆分的位置处已经发生了编辑,就抛出错误。

然后,方法调用 chunk.split(index) 进行实际的拆分操作,得到新的 Chunk 实例 newChunk。接着,更新索引 this.byStart、this.byEnd 以及 this.lastChunk,确保索引和最后的 Chunk 实例正确反映了源代码的结构。

lastSearchedChunk也被更新为正确的值。

通过这个逻辑,源码可以在指定的索引位置处被正确地拆分,确保了对源码的准确修改和操作。

我们直接看看chunk.split做了什么

split(index) {
  // 计算切片的索引
  const sliceIndex = index - this.start;

  // 在指定索引位置切割原始字符串,得到拆分点前和拆分点后的部分
  const originalBefore = this.original.slice(0, sliceIndex);
  const originalAfter = this.original.slice(sliceIndex);

  // 更新当前 chunk 的 original 和 content 属性
  this.original = originalBefore;

  // 创建新的 chunk 实例,表示拆分点后的部分
  const newChunk = new Chunk(index, this.end, originalAfter);
  newChunk.outro = this.outro;
  this.outro = '';

  // 更新当前 chunk 的结束位置
  this.end = index;

  // 如果当前 chunk 已经被编辑过,将新 chunk 设置为空字符串,否则保持原始内容
  if (this.edited) {
    newChunk.edit('', false);
    this.content = '';
  } else {
    this.content = originalBefore;
  }

  // 更新 chunk 之间的关联关系,包括前后关系和当前 chunk 的 next 和新 chunk 的 previous
  newChunk.next = this.next;
  if (newChunk.next) newChunk.next.previous = newChunk;
  newChunk.previous = this;
  this.next = newChunk;

  return newChunk; // 返回新的 chunk 实例
}

在这个方法中,index 参数表示拆分点的位置。它首先计算出拆分点在当前 Chunk 实例中的位置,然后将原始字符串切割为两部分:拆分点前和拆分点后。

接着,创建一个新的 Chunk 实例 newChunk,表示拆分点后的部分。将当前 Chunk 实例的 original 更新为拆分点前的部分,同时将 outro 属性赋给 newChunk 的 outro,并将当前 Chunk 实例的 outro 置为空字符串。

然后,更新当前 Chunk 实例的结束位置为拆分点位置,根据当前 Chunk 是否已经编辑过,将 content 更新为拆分点前的部分或者置为空字符串。

最后,更新新 Chunk 和当前 Chunk 之间的关联关系,包括前后关系和 next、previous 属性的设置。最终,方法返回新的 Chunk 实例,表示拆分点后的部分。

此时, this.byStart、this.byEnd 以及 this.lastChunk,lastSearchedChunk都是正确的值了,我们前面只说做了响应的操作,我们来看看具体的操作是什么。

  // 查找替换范围的第一个和最后一个Chunk实例
  const first = this.byStart[start];
  const last = this.byEnd[end];

  if (first) {
    // 如果存在第一个Chunk实例,则在范围内进行替换
    let chunk = first;
    while (chunk !== last) {
      if (chunk.next !== this.byStart[chunk.end]) {
        throw new Error('Cannot overwrite across a split point');
      }
      chunk = chunk.next;
      chunk.edit('', false);
    }

    // 在第一个Chunk实例上进行编辑
    first.edit(content, storeName, !overwrite);
  } else {
    // 如果没有第一个Chunk实例,则表示在范围之后追加内容
    const newChunk = new Chunk(start, end, '').edit(content, storeName);

    // 更新最后一个Chunk实例的next属性
    last.next = newChunk;
    newChunk.previous = last;
  }
  return this;

首先会检测first是否存在,经过前面的逻辑,可能会有人说,first不是一定存在的吗?

并不是的,在_split中,是一起判断了起始索引和结束索引,所以说,如果起始索引在byEnd中,那么并不会构建byStart。

因此first也可能不存在。

这里问题又来了,我们在update开始的逻辑就判断了起始索引和结束索引的合理性,那么什么情况下,起始索引在在byEnd中呢,答案是第一个参数是开区间结束索引,第二个值是负值的情况。

比如下面这种情况

const s = new MagicString('myName = lumozx');
s.update(15, -4, 'lin');
s.toString(); // myName = luline

这种情况让起始索引和结束索引角色互换,但实际代码逻辑是,还默认第一个为起始索引。

所以,如果存在first,说明在范围内发生了拆分,所以需要迭代所有的Chunk实例,将它们的内容置为空字符串,表示删除内容。然后,将第一个Chunk实例的内容更新为给定的content,使用给定的storeName和overwrite参数。

如果没有first,则说明在范围的末尾插入了新的内容。在这种情况下,创建一个新的Chunk实例newChunk,表示要插入的内容。

然后,将最后一个Chunk实例的next属性指向新的Chunk实例,将新的Chunk实例的previous属性指向最后一个Chunk实例。

这样就将新的内容插入到了原始字符串的末尾。

我们看到,字符串的编辑使用的是chunk的edit方法。

edit(content, storeName, contentOnly) {
  // 设置新的内容
  this.content = content;

  // 如果 contentOnly 为 false,则清空 intro 和 outro,即清空附加/前置的内容
  if (!contentOnly) {
    this.intro = '';
    this.outro = '';
  }

  // 存储 storeName
  this.storeName = storeName;

  // 标记 Chunk 已经被编辑过
  this.edited = true;

  // 返回当前 Chunk 实例,以便进行链式操作
  return this;
}

整个方法比较简单,就是字面意义上的字符串编辑,然后将storeName更新。

而contentOnly相关的逻辑,我们之后再讲。

所以总结一下update的流程。

  • 首先,方法会校验传入的参数是否符合要求,包括入参必须是字符串,start 和 end 不能超出源码字符串的边界。
  • 然后使用 _split(start) 和 _split(end) 方法来构建byStart和byEnd。确保了要更新的范围内的 Chunk 正好位于一个 Chunk 的起始位置和另一个 Chunk 的末尾位置之间。
  • 方法会根据 start 位置来查找范围内的第一个 Chunk 实例。
  • 如果找到了范围内的第一个 Chunk,方法会遍历范围内的所有 Chunk。然后,将范围内的 Chunk 更新为空字符串,最后,将新的 content 插入到 Chunk 中,如果指定了 storeName,还会将原始名称存储起来。如果没有找到第一个 Chunk(即范围的起始位置在一个 Chunk 的末尾),则会在范围的末尾创建一个新的 Chunk,并将 content 插入其中。
  • 最后会返回更新后的 MagicString 实例,方便链式调用。

prepend / append

prepend的作用实际上是针对intro的更新。

prepend(content) {
  // 检查 content 是否为字符串,若不是则抛出类型错误
  
  // 将 content 添加到 this.intro 前面
  this.intro = content + this.intro;
  return this;
}

相对的 append的作用是针对outro的更新。

append(content) {
  // 检查 content 是否为字符串,若不是则抛出类型错误

  // 将 content 添加到 this.outro 后面
  this.outro += content;
  return this;
}

toString

toString的代码就比较简单了。

我们通过update知道了,我们实际上所有的修改都是保存在了chunk里面,所以toString就是把所有的chunk拼接起来。

toString() {
  // 初始化源码字符串为 intro 部分
  let str = this.intro;

  // 从第一个 Chunk 实例开始遍历
  let chunk = this.firstChunk;
  while (chunk) {
    // 将当前 Chunk 实例的内容追加到源码字符串中
    str += chunk.toString();
    // 获取下一个 Chunk 实例
    chunk = chunk.next;
  }

  // 将 outro 部分的内容追加到源码字符串末尾
  return str + this.outro;
}

// chunk.toString
toString() {
    return this.intro + this.content + this.outro;
}

intro是字符串前缀,所以需要一开始就加入结果字符串,然后找到第一个chunk,然后通过chunk.next不断找到新的chunk,然后使用chunk.toString获取当前的字符串,而chunk.toString是由chunk自己的前缀,以及自己的内容,和自己的后缀组成。

循环chunk之后,附带上公共的后缀。

在初始化的时候,会定义firstChunk为默认的chunk,虽然针对firstChunk的更改只有move方法,但由于内存共享,chunk也会在chunk.split被更改。

prependLeft / prependRight / appendLeft / appendRight

前面我们讲过了prepend,是给整个字符串加前缀,如果我想精确定位到某个索引,给这个索引前面加入前缀呢?那么我们可能需要用prependLeft或者prependRight。

他们有什么区别呢?

const s = new MagicString('abc');
s.prependLeft(1,'2'); // a2bc
s.prependRight(1,'4'); // a24bc
s.prependLeft(1,'4');// a424bc

他们的相同点都是增加前缀,理论上prependLeft会将前缀增加到索引对应chunk的intro的左侧,prependRight会将前缀增加到索引对应chunk的intro的右侧。

为什么是理论上呢?

我们看看他们的代码。

prependLeft(index, content) {
  // 检查 content 是否为字符串,若不是则抛出类型错误

  // 分割 Chunk,确保 index 处存在一个 Chunk,方便插入
  this._split(index);

  // 获取在 index 处结束的 Chunk
  const chunk = this.byEnd[index];

  if (chunk) {
    // 若存在 Chunk,则在该 Chunk 的右侧插入内容
    chunk.prependLeft(content);
  } else {
    // 若不存在 Chunk,则在 intro 部分的左侧插入内容
    this.intro = content + this.intro;
  }
  
  // 返回当前 Chunk 实例,以便链式调用
  return this;
}
prependRight(index, content) {
  // 检查 content 是否为字符串,若不是则抛出类型错误
  // 分割 Chunk,确保 index 处存在一个 Chunk,方便插入
  // 获取在 index 处开始的 Chunk
  const chunk = this.byStart[index];
  if (chunk) {
    // 若存在 Chunk,则在该 Chunk 的左侧插入内容
    chunk.prependRight(content);
  } else {
    // 若不存在 Chunk,则在 outro 部分的左侧插入内容
    this.outro = content + this.outro;
  }
  // 返回当前 Chunk 实例,以便链式调用
}

这里需要注意的是,他们先调用了_split方法,然后才会判断byStart、byEnd是否存在,如果存在那么就将调用chunk的对应方法。

但实际上,prependLeft会优先从byEnd寻找匹配的索引,如果存在,那么就会将字符串添加到那个索引的后缀。

在toString的方法中,我们介绍了chunk本身也是有前缀后缀的。

prependRight方法,会优先从byStart寻找匹配的索引,如果存在,那么就会将字符串添加到那个索引的前缀。

但是在视觉上,我们的确往目标索引前面加入了新的字符串。

appendLeft和appendRight与上文逻辑类似,对此不再赘述。

overwrite

overwrite实际上是update的封装。

overwrite(start, end, content, options) {
  options = options || {};

  // 调用 update 方法,将 overwrite 选项设置为 !options.contentOnly,即默认为 true
  return this.update(start, end, content, { ...options, overwrite: !options.contentOnly });
}

换句话说,是自动将options.contentOnly设置为true,然后调用update。而contentOnly逻辑在update中提到了,会自动将前缀和后缀置空。

所以overwrite与update不同点在于调用chunk.edit,默认情况下,overwrite会自动清除chunk的前缀和后缀。

这里传入逻辑是需要注意的,传入chunk.edit的时候,使用的是!overwrite,而edit判断能否清除前缀和后缀,使用的是!contentOnly。又给反过来了。

举个例子。

const s = new MagicString("abc")
s.prependLeft(1, "A") // aAbc
s.update(0, 1, "1") // 1Abc
s.overwrite(0, 1, "2") // 2bc

这个例子定义了abc,然后将b的前缀加上A。

然后将a变更为1。

使用overwrite变更原文a的位置,让他变更为2,A被清除。

因为prependLeft是从byEnd寻找的chunk,索引索引A是被加入a后面,而不是b

move

move方法用将从start到end的字符移动到索引index后面

move(start, end, index) {
  // 如果索引在选择范围内,抛出错误

  // 确保相关位置的 chunk 已被拆分
  this._split(start);
  this._split(end);
  this._split(index);

  // 获取被移动的选择范围的第一个和最后一个 chunk
  const first = this.byStart[start];
  const last = this.byEnd[end];

  // 获取被移动的选择范围的前一个和后一个 chunk
  const oldLeft = first.previous;
  const oldRight = last.next;

  // 获取被移动的选择范围插入到的新位置的前一个和后一个 chunk
  const newRight = this.byStart[index];
  if (!newRight && last === this.lastChunk) return this;
  const newLeft = newRight ? newRight.previous : this.lastChunk;

  // 更新原位置和新位置的前后 chunk 的关系
  if (oldLeft) oldLeft.next = oldRight;
  if (oldRight) oldRight.previous = oldLeft;

  if (newLeft) newLeft.next = first;
  if (newRight) newRight.previous = last;

  // 更新首尾 chunk 的指针
  if (!first.previous) this.firstChunk = last.next;
  if (!last.next) {
    this.lastChunk = first.previous;
    this.lastChunk.next = null;
  }

  // 更新被移动的选择范围的第一个和最后一个 chunk 的前一个和后一个指针
  first.previous = newLeft;
  last.next = newRight || null;

  // 如果新位置的前一个 chunk 为空,更新首 chunk 指针
  if (!newLeft) this.firstChunk = first;
  // 如果新位置的后一个 chunk 为空,更新尾 chunk 指针
  if (!newRight) this.lastChunk = last;
  return this;
}

函数首先检查索引是否在选择范围内,接着,函数调用 _split 方法确保操作涉及的位置已被拆分。

然后获取被移动的选择范围的第一个和最后一个chunk以及它们的前后chunk。

在前面,我们提到了firstChunk会被move更改,所以接下来,函数更新原位置和新位置的前后chunk的关系,调整首尾chunk的指针。

sourcemap

在了解magic-string如何构建sourcemap之前,我们先了解一下,什么是sourcemap。

我们知道,前端代码是可以打包压缩、混淆的,但有没有一种工具,让我们构建的产物还原成源码的状态?

sourceMap协议正是为了解决此问题诞生的协议,最初的map文件非常大,V2版本引入base64编码等算法,体积减小20%~30%,V3版本又引入VLQ算法,体积进一步压缩50%,目前我们使用的正是V3版本,也是magic-string所构建的版本。

结构

V3版本的Sourcemap文件由三个部分组成:

  • 原始代码
  • 经过处理后的打包代码,且产物文件中必须包含指向Sourcemap文件地址的 //# sourceMappingURL=XXX指令
  • 记录源码与打包代码位置映射关系的map文件

正常页面只加载打包后的代码,只有特定事件才会加载map文件——比如打开控制台。比如我们一开始例子,最终结果如下。

// onverted.js
const thisIsMyName = alice;

map文件通常是json格式。下面就是magic-string生成的map文件。

{
  "version": 3,
  "file": "converted.js.map",
  "sources": [
    "source.js"
  ],
  "sourcesContent": [
    "myName = lumozx"
  ],
  "names": [],
  "mappings": "MAAA,YAAM,GAAG"
}
  • version:指sourcemap版本
  • names:字符串数组,记录原始代码出现的变量名,这里需要注意的是,如果没有混淆原始代码的变量名,这一项是空的
  • file:sourcemap对应的打包产物
  • sourcesContent:原始代码内容
  • sourceRoot: 源文件根目录
  • sources:源文件目录
  • mappings:与原始代码的映射关系

在浏览器读取的时候,会根据mappings的数值关系,将代码映射到sourcesContent,从而还原到源码的文件、行、列,因此不难看出,map文件的重点就是mappings字段。

那么mappings中的是什么意思呢?

  • 第一位是该代码片段在产物的列数
  • 第二位是源码文件的索引,对应的是sources数组的元素下标
  • 第三位是该代码片段在源码的行数
  • 第四位是该代码片段在源码的列数
  • 如果有第五位的话,对应的名称索引,就是该片段在names数组的元素下标,如前面所说,如果没有混淆等方式更改变量名称,此项为空,names也为空

除了这些信息,还有个隐藏信息,那么就是mappings解析出来的行数是与产物一一对应的,因此通过产物所在的列数,就可以找到mappings对应的映射,再通过映射找到源码。

这里需要注意的是,片段之间并非绝对定位,而是代码片段的相对偏移定位。

比如AACAC,OAAO他们组合成为了一个代码片段,那么他们的第一位分别是

  • A,第A列
  • O,第A + O列

同时,不同行之间也有偏移,比如 AAAA,AACA,AACA,那么他们的第三位是

  • A,第A行
  • C,第A + C行
  • C,第A + C + C行

我们来解析一下mappings。不过得了解一下VLQ。

VLQ

VLQ是一种将整数数值转换为Base64的编码算法,它先将任意大的整数转换为一系列六位字节码,再按Base64 规则转换为一串可见字符。VLQ使用六位比特存储一个编码分组。

就拿4来举例,4经过VLQ编码后,结果是001000

  • 第一位是连续符号位,标识后续分组是否是同一数字,因为VLQ是六位比特为一个分组,存在一个数组用多个分组来表示的情况,因此除了最后一个分组为0,其他分组第一位都为1
  • 第六位标识该数字的正负号,0为正整数,1为负整数
  • 2-5标识实际数组,若不足,则左侧填充0
  • 先添加符号位,再分组,分组方式是从后往前分组,但分组也将颠倒,然后再填充不足的数字,最后添加连续符号位

image.png

经过变化,4变为了001000,是二进制的8,查表得,4的映射字符是I。

为了加深理解,我们这次来按部就班写出-25的映射编码。

  1. 首先25的二进制是11001
  2. 由于是负整数,因此最右侧添加符号位1,变成110011
  3. 由于是六位一组,但没有添加连续符号位,因此针对数字是五位一组,所以空出一位来添加连续符号位,因此分组为【1 ,10011】,由于是从后往前分组,因此整理(也就是颠倒分组)一下,是【10011 ,1】
  4. 不足五位的需要左侧补充0,直到五位,也就是【10011,00001】
  5. 添加连续符号位,除了最后一组是0,其他组最后都是1,也就是【110011,000001】
  6. 然后转换成10进制,110011 => 51 000001 => 2
  7. 最后查表得,51是z,2是B,因此-25的VAL编码是zB

可以使用这个网站,来验证结论是否正确:BASE64 VLQ CODEC

解析

经过上面的了解,我们已经了解了基本的VAL编码,这个时候我们再回头看看mappings的MAAA,YAAM,GAAG,他们复原之后是[6,0,0,0], [12,0,0,6], [3,0,0,3]。

  • [6,0,0,0]意味着,产物第0行的第6列开始的字符串,对应sources第0个索引,源码第0行,第0列开始字符串。也就是产物thisIsMyName对应源码myName。(结束索引由后面一组数字提供)
  • [12,0,0,6]意味着,产物第0行的第12 + 6列开始的字符串,对应sources第0个索引,源码第0行,第0 + 6列开始字符串。也就是产物=对应源码=。(注意=左右是有空格的)
  • [3,0,0,3]意味着,产物第0行的第12 + 6 + 3列开始的字符串,对应sources第0个索引,源码第0行,第3 + 0 + 6列开始字符串。也就是产物alice;对应源码lumozx。

源码

好了,我们已经知道sourcemap是什么,我们看看他是怎么来的。

generateMap(options)调用的是new SourceMap(this.generateDecodedMap(options));

那么我们分两步看,先看看SourceMap做了什么。

class SourceMap {
  constructor(properties) {
    this.version = 3;
    this.file = properties.file;
    // 原始文件名数组
    this.sources = properties.sources;
    // 原始文件内容数组
    this.sourcesContent = properties.sourcesContent;
    // 映射变量数组
    this.names = properties.names;
    // 使用@jridgewell/sourcemap-codec这个包的encode转码
    this.mappings = encode(properties.mappings);
  }
  // 将 SourceMap 对象转换为 JSON 格式的字符串
  toString() {
    return JSON.stringify(this);
  }
  toUrl() {
    return 'data:application/json;charset=utf-8;base64,' + btoa(this.toString());
  }
}

可以看到,SourceMap的实例就是最终map的对象,而里面的参数,是通过properties获取的。

也就是通过this.generateDecodedMap(options)得到的。

generateDecodedMap做了什么。

generateDecodedMap(options) {
  // 设置默认参数为空对象
  options = options || {};
  // 源索引
  const sourceIndex = 0;
  // 存储的名字数组
  const names = Object.keys(this.storedNames);
  // Mappings 类的实例,用于生成映射
  const mappings = new Mappings(options.hires);
  // 获取源码定位函数
  const locate = getLocator(this.original);
  // 前缀
  if (this.intro) {
    mappings.advance(this.intro);
  }
  // 迭代每个 chunk
  this.firstChunk.eachNext((chunk) => {
    // 获取 chunk 的开始位置的源码定位信息
    const loc = locate(chunk.start);

    // 前缀
    if (chunk.intro.length) mappings.advance(chunk.intro);

    // 如果 chunk 被编辑过
    if (chunk.edited) {
      mappings.addEdit(
        sourceIndex,
        chunk.content,
        loc,
        chunk.storeName ? names.indexOf(chunk.original) : -1
      );
    } else {
      mappings.addUneditedChunk(sourceIndex, chunk, this.original, loc, this.sourcemapLocations);
    }

    // 后缀
    if (chunk.outro.length) mappings.advance(chunk.outro);
  });

  // 返回解码后的映射信息对象
  return {
    // 提取文件名,如果 options 中有 file 属性
    file: options.file ? options.file.split(/[/\\]/).pop() : null,
    // 提取源文件路径,如果 options 中有 source 属性
    sources: [options.source ? getRelativePath(options.file || '', options.source) : null],
    // 是否包含源文件内容
    sourcesContent: options.includeContent ? [this.original] : [null],
    // 存储的名字数组
    names,
    // 获取原始映射字符串
    mappings: mappings.raw,
  };
}

generateDecodedMap 函数通过迭代源码的每个 chunk,根据其前缀、后缀以及是否被编辑过,更新映射位置,最终返回解码后的映射信息对象,包括

  • 文件名
  • 源文件路径
  • 源文件内容是否包含在内
  • 存储的名字数组
  • 最终的原始映射字符串。

从而源码上看,大部分是直接获取options的属性,比如options.source指定了源文件,它并不会校验真实性,而是直接返回。

但也有例外,比如names和mappings。

names实际是storedNames转化的数组,storedNames之前提到了它的来源。

而mappings是怎么来的呢?我们看到是直接new Mappings得到的。我们直接看看Mappings做了是什么。

class Mappings {
  // 构造函数接受一个布尔值,表示是否使用hires
  constructor(hires) {
    this.hires = hires;
    this.generatedCodeLine = 0; // 生成代码的当前行
    this.generatedCodeColumn = 0; // 生成代码的当前列
    this.raw = []; 
    this.rawSegments = this.raw[this.generatedCodeLine] = []; // 当前原始段数组
    this.pending = null; // 待添加的待定段
  }

  // 将编辑后的段添加映射
  addEdit(sourceIndex, content, loc, nameIndex) {
    if (content.length) {
      const segment = [this.generatedCodeColumn, sourceIndex, loc.line, loc.column];
      if (nameIndex >= 0) {
        segment.push(nameIndex);
      }
      this.rawSegments.push(segment);
    } else if (this.pending) {
      this.rawSegments.push(this.pending);
    }

    this.advance(content);
    this.pending = null;
  }

  // 将未编辑的代码块添加映射
  addUneditedChunk(sourceIndex, chunk, original, loc, sourcemapLocations) {
    let originalCharIndex = chunk.start;
    let first = true;

    while (originalCharIndex < chunk.end) {
      // hires为true、第一次迭代或处于映射位置时添加片段
      if (this.hires || first || sourcemapLocations.has(originalCharIndex)) {
        this.rawSegments.push([this.generatedCodeColumn, sourceIndex, loc.line, loc.column]);
      }

      // 处理换行符,相应地更新行和列
      if (original[originalCharIndex] === '\n') {
        loc.line += 1;
        loc.column = 0;
        this.generatedCodeLine += 1;
        this.raw[this.generatedCodeLine] = this.rawSegments = [];
        this.generatedCodeColumn = 0;
        first = true;
      } else {
        loc.column += 1;
        this.generatedCodeColumn += 1;
        first = false;
      }
      originalCharIndex += 1;
    
    this.pending = null;
  }

  // 根据提供的字符串前进生成的代码位置
  advance(str) {
    if (!str) return;
    const lines = str.split('\n');
    if (lines.length > 1) {
      // 对于多行字符串,递增行数并重置列数
      for (let i = 0; i < lines.length - 1; i++) {
        this.generatedCodeLine++;
        this.raw[this.generatedCodeLine] = this.rawSegments = [];
      }
      this.generatedCodeColumn = 0;
    }

    // 基于最后一行的长度增加列数
    this.generatedCodeColumn += lines[lines.length - 1].length;
  }
}

Mappings 类是 magic-string 中用于生成sourcemap的核心组件。

它通过记录生成代码的行、列信息以及处理编辑和未编辑的代码块,最终生成mappings。

在编辑时,根据内容的长度构建编辑后的源映射段,而在处理未编辑的代码块时,根据hires的值、是否是第一次迭代以及源映射位置信息,决定是否添加原始源映射段。

在generateDecodedMap中,如果有前缀,那么使用mappings.advance函数添加前缀。因为前缀肯定不存在源码中,所以会给予空的映射关系,可以理解为不会处理映射关系,只会处理行数和列数。

接着如同toString的逻辑,从firstChunk开始迭代,如果chunk被编辑过,那么调用mappings.addEdit,反之,调用mappings.addUneditedChunk。

他们都会更新raw数组,也就是更新映射关系。

他们之间区别是,mappings.addEdit会使用新的字符串,默认跟原始字符串不同,因为可能存在换行,因此需要使用mappings.advance处理列和行。

mappings.addUneditedChunk由于没有编辑过,因此使用原始字符处理列和行。

只要调用过chunk.edit,都会将是否编辑过的标记edited置为true。

而chunk.edit触发的地方就很多了,比如chunk.split、update、remove、trimEnd等。

当然,还没结束,还有后缀需要处理,处理方式跟前缀一样。处理完后缀后。

mappings.raw就是最终的mappings。

上文说更新raw数组,那么是怎么更新的呢?我们通过源码可以看到,在constructor、addUneditedChunk和advance都会让rawSegments指向一个空数组,然后推入raw中,每次更新只需要更新当前行——rawSegments即可。

待到未编辑chunk换行或者advance触发换行,才会让rawSegments指向一个空数组,然后raw使用新行索引指向rawSegments,其他时间更新rawSegments默认是更新当前行(因为内存共享)。