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
先添加符号位,再分组,分组方式是从后往前分组,但分组也将颠倒,然后再填充不足的数字,最后添加连续符号位
经过变化,4
变为了001000
,是二进制的8
,查表得,4
的映射字符是I
。
为了加深理解,我们这次来按部就班写出-25
的映射编码。
首先
25
的二进制是11001
由于是负整数,因此最右侧添加符号位
1
,变成110011
由于是六位一组,但没有添加连续符号位,因此针对数字是五位一组,所以空出一位来添加连续符号位,因此分组为
【1 ,10011】
,由于是从后往前分组,因此整理(也就是颠倒分组)一下,是【10011 ,1】
不足五位的需要左侧补充
0
,直到五位,也就是【10011,00001】
添加连续符号位,除了最后一组是
0
,其他组最后都是1
,也就是【110011,000001】
然后转换成
10
进制,11001
1 =>51 000001
=>2
最后查表得,
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
默认是更新当前行(因为内存共享)。