magic-string 是一个字符串工具,提供了一些方法去操作字符串,以及生成source map。
作者是Rich Harris,看Github首页就能看出又是大佬一枚(svelte、rollup…)
image.png

按照就用yarn或者npm按照就好。
把待操作的字符串作为参数传给MagicString的构造函数就好:

基本用法说明

  1. var s = new MagicString( 'problems = 99' );
  • snip 相当于子串,返回新的magic string对象,s持有的引用不变 ```javascript const str = ‘yxnne is a real man’ const s = new MagicString(str)

log(s.snip(0, 6).toString()) // yxnne log(s.toString()) // yxnne is a real man

  1. - 按照索引替换字符:overwrite方法
  2. ```javascript
  3. const str = 'yxnne is a real man'
  4. const s = new MagicString(str)
  5. log(s.overwrite(0, 6, 'wx ').toString()) // wx is a real man
  6. log(s.toString()) // wx is a real man

这个结果可以猜到,原来的s打印出来也改变了,这时候再去调用一次overwrite:

  1. log(s.overwrite(0, 6, '666 ').toString()) // 666 is a real man
  2. log(s.toString()) // 666 is a real man

神奇的点在哪里呢?发现没 overwirte的索引始终是按照第一次的初始字符串来计算的,也就是说我们从’wx is a real man’变为’666 is a real man’并不是取下标[0,2),而依旧是[0, 6)

(其他的字符串方法参照官网:https://github.com/Rich-Harris/magic-string#methods

  • 批量合并:bundle

下面的代码中我们通过addSource添加了两段string:var answer = 42;和console.log( answer ):

  1. const MagicString = require('magic-string')
  2. const log = console.log
  3. // 初始化bundle
  4. const bundle = new MagicString.Bundle();
  5. bundle.addSource({
  6. filename: 'foo.js', // filename 为了sourcemap
  7. content: new MagicString('var answer = 42;') // 内容
  8. });
  9. bundle.addSource({
  10. filename: 'bar.js',
  11. content: new MagicString('console.log( answer )')
  12. });

我们期望把增加的这两段代码合并起来,同时在最终生成的字符串前后包裹一个函数,并且使函数体内容有缩进:

  1. bundle.indent() // 统一加上缩进
  2. .prepend('(function () {\n') // 前面添加
  3. .append('\n}());'); // 后面添加
  4. log(bundle.toString())

最终打印结果:

  1. (function () {
  2. var answer = 42;
  3. console.log( answer )
  4. }());
  • 生成sourcemap ```javascript var map = s.generateMap({ source: ‘source.js’, file: ‘converted.js.map’, includeContent: true }); // generates a v3 sourcemap

require( ‘fs’ ).writeFile( ‘converted.js’, s.toString() ); require( ‘fs’ ).writeFile( ‘converted.js.map’, map.toString() );

  1. <a name="p1SUY"></a>
  2. ### 源码
  3. 其实就是想看看这个代码是怎么实现上面overwirte的这种特性.
  4. <a name="C6klD"></a>
  5. #### constructor
  6. 先看下构造函数吧,在src/MagicString.js:
  7. ```javascript
  8. // 核心接受的参数是string
  9. constructor(string, options = {}) {
  10. // 构造了一个chunk对象
  11. const chunk = new Chunk(0, string.length, string);
  12. // 定义属性
  13. Object.defineProperties(this, {
  14. original: { writable: true, value: string }, // 原始串
  15. outro: { writable: true, value: '' },
  16. intro: { writable: true, value: '' },
  17. firstChunk: { writable: true, value: chunk }, // chunk
  18. lastChunk: { writable: true, value: chunk }, // chunk
  19. lastSearchedChunk: { writable: true, value: chunk }, // chunk
  20. byStart: { writable: true, value: {} },
  21. byEnd: { writable: true, value: {} },
  22. filename: { writable: true, value: options.filename },
  23. indentExclusionRanges: { writable: true, value: options.indentExclusionRanges },
  24. sourcemapLocations: { writable: true, value: new BitSet() },
  25. storedNames: { writable: true, value: {} },
  26. indentStr: { writable: true, value: guessIndent(string) }
  27. });
  28. if (DEBUG) {
  29. Object.defineProperty(this, 'stats', { value: new Stats() });
  30. }
  31. this.byStart[0] = chunk;
  32. this.byEnd[string.length] = chunk;
  33. }

上面构造函数,接受的参数核心是string,构造函数中做的事情有:

  1. 构造了一个chunk对象(什么是chunk后面再看)
  2. 定义了一堆属性,其中:
    1. original就是最初的字符串
    2. 发现了三个和chunk相关的属性firstChunk、lastChunk、lastSearchedChunk他们的初始值都是刚刚构造的chunk对象
    3. byStart[0]、byEnd[string.length]也都赋值为刚刚的chunk对象

那么,chunk是啥?

Chunk

我们看src/Chunk.js中的构造函数:

  1. constructor(start, end, content) {
  2. this.start = start;
  3. this.end = end;
  4. this.original = content;
  5. this.intro = '';
  6. this.outro = '';
  7. this.content = content;
  8. this.storeName = false;
  9. this.edited = false;
  10. // we make these non-enumerable, for sanity while debugging
  11. Object.defineProperties(this, {
  12. previous: { writable: true, value: null },
  13. next: { writable: true, value: null }
  14. });
  15. }

结合刚刚在MagicString的constructor中使用const chunk = new Chunk(0, string.length, string); 可以看出,chunk也是一个字符串的封装,其中chunk.start、chunk.end表示了位置信息,chunk.content是字符串内容。
另外在,contructor中还有一个非常重要的点:

  1. Object.defineProperties(this, {
  2. previous: { writable: true, value: null },
  3. next: { writable: true, value: null }
  4. });

previous、next这…这是双向链表呀。
看到这里,大致就能猜测出MagicString的工作原理了,就是通过chunk形成的链表对应原始字符串的一个部分,相当于每一个chunk就是一个“补丁”。
为了印证这个想法,看看最终MagicString字符串的输出是怎么输出的吧,那就是toString。

toString方法

我们每次得到结果调用的都是这个重载方法:

  1. toString() {
  2. let str = this.intro;
  3. let chunk = this.firstChunk;
  4. while (chunk) {
  5. str += chunk.toString();
  6. chunk = chunk.next;
  7. }
  8. return str + this.outro;
  9. }

可以看到,确实是如上文所想,magicstring的toString方法就是将chunk链表遍历一遍,分别调用其chunk.toString然后做出的字符串拼接。

现在我们已经基本理解了magicstring的数据结构

clone方法

clone方法顾名思义,自然是返回一个克隆的magicstring对象,那么,我们很自然的要把里面涉及的数据结构属性都克隆一遍:

  1. clone() {
  2. // new一个新对象
  3. const cloned = new MagicString(this.original, { filename: this.filename });
  4. // 拿到old对象的第一个chunk
  5. let originalChunk = this.firstChunk;
  6. // 初始化新对象的几个chunk,chunk也克隆一份
  7. let clonedChunk = (cloned.firstChunk = cloned.lastSearchedChunk = originalChunk.clone());
  8. // 从第一个chunk开始遍历 分别克隆并组装数据结构
  9. while (originalChunk) {
  10. cloned.byStart[clonedChunk.start] = clonedChunk;
  11. cloned.byEnd[clonedChunk.end] = clonedChunk;
  12. const nextOriginalChunk = originalChunk.next;
  13. const nextClonedChunk = nextOriginalChunk && nextOriginalChunk.clone();
  14. if (nextClonedChunk) {
  15. clonedChunk.next = nextClonedChunk;
  16. nextClonedChunk.previous = clonedChunk;
  17. clonedChunk = nextClonedChunk;
  18. }
  19. originalChunk = nextOriginalChunk;
  20. }
  21. cloned.lastChunk = clonedChunk;
  22. // 先不管
  23. if (this.indentExclusionRanges) {
  24. cloned.indentExclusionRanges = this.indentExclusionRanges.slice();
  25. }
  26. // 先不管
  27. cloned.sourcemapLocations = new BitSet(this.sourcemapLocations);
  28. cloned.intro = this.intro;
  29. cloned.outro = this.outro;
  30. return cloned;
  31. }

上面代码中核心做的事情就是:构造了一个新的对象,拿到旧对象的第一个chunk,初始化新对象的几个chunk,然后构造新对象的chunk链表:从第一个chunk开始遍历,分别克隆,然后链接起来。

snip方法

  1. snip(start, end) {
  2. const clone = this.clone();
  3. clone.remove(0, start);
  4. clone.remove(end, clone.original.length);
  5. return clone;
  6. }

snip方法看上去逻辑比较简单:就是clone一个对象,移除0-start和end-length的区间,也就是说保留(start, end]之间的这段。
不过remove的逻辑其实挺复杂的,简单看了下,主要也是在操作chunk,这就不深究了。

overwrite方法

overwrite方法其实就是我最想弄明白的方法,我最初就是想知道:
const str = ‘yxnne is a real man’
const s = new MagicString(str)
这样的magic string,第一次overwrite之后的结果我可以理解:
log(s.overwrite(0, 6, ‘wx ‘).toString()) // wx is a real man
log(s.toString()) // wx is a real man
不过再次overwrite,传的下标还是0,6但是能达到预期的效果:
log(s.overwrite(0, 6, ‘666 ‘).toString()) // 666 is a real man
log(s.toString()) // 666 is a real man

现在基本是知道怎么做的了,但是最好还是结合代码整体看下:

  1. overwrite(start, end, content, options) {
  2. // 1. 边界处理
  3. if (typeof content !== 'string') throw new TypeError('replacement content must be a string');
  4. while (start < 0) start += this.original.length;
  5. while (end < 0) end += this.original.length;
  6. if (end > this.original.length) throw new Error('end is out of bounds');
  7. if (start === end)
  8. throw new Error('Cannot overwrite a zero-length range – use appendLeft or prependRight instead');
  9. if (DEBUG) this.stats.time('overwrite');
  10. // 2. 拆分
  11. this._split(start);
  12. this._split(end);
  13. if (options === true) { } // 处理options 忽略
  14. const storeName = options !== undefined ? options.storeName : false;
  15. const contentOnly = options !== undefined ? options.contentOnly : false;
  16. if (storeName) {
  17. const original = this.original.slice(start, end);
  18. this.storedNames[original] = true;
  19. }
  20. // 3. 构造chunk链,重点
  21. const first = this.byStart[start];
  22. const last = this.byEnd[end];
  23. if (first) {
  24. if (end > first.end && first.next !== this.byStart[first.end]) {
  25. throw new Error('Cannot overwrite across a split point');
  26. }
  27. first.edit(content, storeName, contentOnly);
  28. if (first !== last) {
  29. let chunk = first.next;
  30. while (chunk !== last) {
  31. chunk.edit('', false);
  32. chunk = chunk.next;
  33. }
  34. chunk.edit('', false);
  35. }
  36. } else {
  37. // must be inserting at the end
  38. const newChunk = new Chunk(start, end, '').edit(content, storeName);
  39. // TODO last chunk in the array may not be the last chunk, if it's moved...
  40. last.next = newChunk;
  41. newChunk.previous = last;
  42. }
  43. if (DEBUG) this.stats.timeEnd('overwrite');
  44. return this;
  45. }

上面代码核心部分已经标注出来:

  1. 边界处理:处理下下标,也支持了负数下标

    1. // 负数下标
    2. while (start < 0) start += this.original.length;
    3. while (end < 0) end += this.original.length;
  2. _split拆分

这个方法的调用很重要,源代码就不贴了,只描述下做了件什么事:_split(index),就是按照index,把当前index命中的下标对应的chunk给它变成两个new chunk,并且首位链接起来。
比如现在是一个新的字符串abcdefg 下标就是0,1,2,3,4,5,6,最开始magic string只有一个chunk,我们记作chunk:0-6, 现在假设调用_split(3),那么这时候生成了两个chunk1:0-3, chunk2:3-6,而且要让他们成为双链表,即:chunk1.next = chunk2 、 chunk2.next = chunk1
还有一件重要的事情就是记录这些chunk的起始位置,在magicstring的结构里面有属性this.byStart、this.byEnd,他们都是数组,下标表示原始字符串字符的下标,值就是chunk或者null,如果某一个下标做为一段chunk的开始,就设置这个chunk为值,这样来记录chunk位置。
比如上面的例子中,生成chunk1:0-3, chunk2:3-6,那么:
this.byStart[0] = chunk1, this.byStart[3] = chunk2

  1. 替换chunk

有了上面这一步作为基础,第三步很重要,但是确很好理解了,简单阐述下就是要把需要重写的chunk替换掉。
overwirte(start, end, content),假设最简单的情况,在overwirte之前,chunk只有一个,那么被start和end来_split拆分后就生成了首尾相连的三个chunk,这时候要替换的就是中间的chunk, content是他的内容:

  1. const newChunk = new Chunk(start, end, '').edit(content, storeName);

但是怎么找到这个chunk呢?
上文说了,this.byStart这个属性记录就是我们的chunk,即:this.byStart[start]
从这种简单的情况推演出复杂的情况,其实也是差不多,就是拆分chunk,找到chunk,替换chunk,当然还涉及了我需要替换的内容跨越了好几个chunk的问题,这种源码里面也做了处理,就不展开了。

最后再简单总结下,magic-string这个libary就是通过chunks双向链表这种数据结构来完成它的操作的,类似打补丁的感觉。