记一次使用`stylus-converter`将stylus转scss的过程

公司的前端项目基本上都是用的scss预处理器,但是有两个Vue项目因为历史原因是stylus和scss混用的情况,所以有了将stylus转为scss的需求。经过一番搜索,我们找到了 stylus-converter 这个插件,但是仍然还存在一些需要手动机解决的问题。

发现问题

首先按照教程操作一波目录的转译:

# 下载 stylus-converter
npm install -g stylus-converter

# 进入项目目录
mv src src-temp

# 运行 cli 转换目录
stylus-conver -d yes -i src-temp -o src
复制代码

很快就转译结束了,赶紧run一下,结果马上出问题:

These relative modules were not found:

* xxx/xxx.styl in ./src/main.js
复制代码

好家伙,原来在入口文件引入的一个styl文件被改成scss文件后找不到了,这个插件只编译了.stylus后缀的文件和 .vue中的包含stylus 字符串的<style>标签,源码中对应的正则是/\.styl$//\.vue$//<style(.*)>([\w\W]*?)<\/style>/g

手动把import的stylus文件都改成对应的scss文件后,再次查看控制台,发现还是报错,有一堆的mixin找不到import,转译前后的import应该是不会变更的,为什么在转移后就找不到了呢?猜想是配置了stylus的全局导入,一看webpack的配置果然如此,项目用来style-resources-loader 将 stylus样式预导入了全局的mixin,将其改成对应的scss导入即可。

计算表达式转译错误

再次运行,这回是新的报错:

SassError: Expected expression.
   ╷
78 │   flex: 0 0, 100 / $n %;
   │                        ^
   ╵
复制代码

转译前是:flex: 0 0 (100 / n)% 转移后:flex: 0 0, 100 / $n %;

很显然这在sass是一个语法错误,看了下这类数量不多,就手动改掉了,比如这个改成了:flex: 0 0, (100 / $n) *1%;

样式穿透错误

再次运行,发现了新的报错:

SassError: expected selector.
  ╷
5 │     margin: 20px 0 0;/deep/.table{
  │                      ^
  ╵
复制代码

转译前是:

.xxclassname
  margin: 20px 0 0
  >>>.table
    //...
复制代码

看了下源码是这样子替换深度选择器的:

result = result.replace(/(.*)>>>(.*)/g, `$1/deep/$2`)
复制代码

我看了下代码里的深度选择器,都是可以直接通过字符串替换解决的,也就是/deep/替换成::v-deep ,于是一键替换了。

这里还有个注意点,就是可能有这样的代码:/deep/div {,如果简单的替换成::v-deepdiv {那肯定有问题,所以替换的::v-deep 是包含一个空格符的。

Mixin签名差异

再次运行,发现了新的报错:

SassError: Only 0 arguments allowed, but 2 were passed.
   ┌──> src/views/trace/detail.scss
6  │       @include fontHeight(30px, 12px);
   │       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ invocation
   ╵
   ┌──> /Users/grapevine/Documents/chan/cmm-wap/src/views/trace/detail.vue
12 │ @mixin fontHeight() {
   │        ━━━━━━━━━━━━ declaration
   ╵
复制代码

看下源码:

fontHeight()
  if (length(arguments) == 1)
    height: arguments[0]
    line-height: arguments[0]
    font-size: arguments[0]
  else
    height: arguments[0]
    line-height: arguments[0]
    font-size: arguments[1]
复制代码

转移后:

@mixin fontHeight() {
  @if length($arguments) == 1 {
    height: map-get(arguments, 0);
    line-height: map-get(arguments, 0);
    font-size: map-get(arguments, 0);
  } @else {
    height: map-get(arguments, 0);
    line-height: map-get(arguments, 0);
    font-size: map-get(arguments, 1);
  }
}
复制代码

使用这个mxin的地方:

@include fontHeight(30px, 12px);
复制代码

好家伙,原来是stylus的@mixin的签名里参数可以为空,实际使用时可以传入参数,通过arguments关键字去获取参数。而scss必须在mixin签名显示指定参数,于是乎查阅scss文档然后改造了一番:

@mixin fontHeight($arguments...) {
  @if length($arguments) == 1 {
    height: nth($arguments, 1);
    line-height: nth($arguments, 1);
    font-size: nth($arguments, 1);
  } @else {
    height: nth($arguments, 1);
    line-height: nth($arguments, 1);
    font-size: nth($arguments, 2);
  }
}
复制代码

scss可以使用剩余参数,然而比较不符合直觉的是这个剩余参数是index是从1开始的……

好了,到这里控制台就没有报错了,但是还不能高兴太早,run起来看看。

果然最担心的事情还是发生了,没有报错,但是界面上的样式明显是有问题的,经过一番定位,发现了几个问题。

CSS属性同名的Mixin问题

首先发现的是css属性同名的Mixin问题,因为stylus全局定义了一些Mixin,并且用webpack插件进行全局导入,而stylus使用Mixin是不需要显示指定@include,所以会出现如下情况:

转译前:

// 这里定义了一个和css属性同名的mixin
border-top(offset-x, args...)
  //...
复制代码
// 在代码中使用`border-top`,因为全局导入了mixin,所以在作用域中是有 `border-top`这个mixin,所以这里的被解析成了mixin
border-top: 1px;
复制代码

而在转译时,检测到border-top是一个合法css属性,所以不会将其解析成mixin。真是令人头疼。

一个比较好的解决方法是在stylus语法分析时,将和我们定义的mixin同名的css属性标记成mixin而不是普通的属性,但是鉴于对stylus语法分析流程不熟悉,所以退而求其次:先遍历一次找到所有的mixin,收集到和css同名的mixin集合中;然后在遍历第二次,在遍历css属性时判断该属性是否在和css同名的mixin集合中,如果是则打印当前文件名和代码位置,然后手动去修改代码。

说干就干,首先将项目clone到本地,然后用vscode进行调试,编写launch.json:

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "Launch Program",
            "skipFiles": [
                "<node_internals>/**"
            ],
            "program": "${workspaceFolder}/bin/conver.js",
            "args": ["-d","yes", "-i","xxx/test-prj/src-temp", "-o", "xxx/test-prj/src"]
        }
    ]
}
复制代码

通过debug很快就能找到stylus的解析器:node_modules/stylus/lib/parser.js,在Parser这个函数上定义了状态机针对不同词法的解析,我们首先需要找到mixin节点在解析后的节点类型是什么样的。

在lib/index.js中,我们可以找到这样一段代码:

// 开发时查看 ast 对象。
// console.log(JSON.stringify(ast))
复制代码

取消这个注释,我们写一段简单的stylus代码看看ast是什么样的,定义一个mixin:

clear(n)  
  zoom: 1
复制代码

打印结果:

{  
    "__type":"Root",  
    "nodes":[  
        {  
            "__type":"Ident",  
            "name":"clear",  
            "val":{  
                "__type":"Function",  
                "name":"clear",  
                "lineno":2,  
                "column":8,  
                "params":{  
                    "__type":"Params",  
                    "nodes":[  
                        {  
                            "__type":"Ident",  
                            "name":"n",  
                            "val":{  
                                "__type":"Null"  
                            },  
                            "mixin":false,  
                            "lineno":2,  
                            "column":7  
                        }  
                    ],  
                    "lineno":2,  
                    "column":1  
                },  
                "block":{  
                    "__type":"Block",  
                    "scope":true,  
                    "lineno":2,  
                    "column":8,  
                    "nodes":[  
                        {  
                            "__type":"Property",  
                            "segments":[  
                                {  
                                    "__type":"Ident",  
                                    "name":"zoom",  
                                    "val":{  
                                        "__type":"Null"  
                                    },  
                                    "mixin":false,  
                                    "lineno":3,  
                                    "column":3  
                                }  
                            ],  
                            "lineno":3,  
                            "column":3,  
                            "expr":{  
                                "__type":"Expression",  
                                "lineno":3,  
                                "column":9,  
                                "nodes":[  
                                    {  
                                        "__type":"Unit",  
                                        "val":1,  
                                        "lineno":3,  
                                        "column":9  
                                    }  
                                ]  
                            }  
                        }  
                    ]  
                }  
            },  
            "mixin":false,  
            "lineno":3,  
            "column":10  
        }  
    ]  
}
复制代码

可以看到这个节点就是我们要的:

{  
    "val":{  
        "__type":"Function",  
        "name":"clear"  
    }  
}
复制代码

接下来去paser.js中寻找语句的解析:

  parse: function () {
    var block = this.parent = this.root;
    if (Parser.cache.has(this.hash)) {
      block = Parser.cache.get(this.hash);
      // normalize cached imports
      if ('block' == block.nodeName) block.constructor = nodes.Root;
    } else {
      while ('eos' != this.peek().type) {
        this.skipWhitespace();
        if ('eos' == this.peek().type) break;
        // 语句的解析
        var stmt = this.statement(input);
        // 在这里打印看看stmt是什么
        console.log(stmt);
        this.accept(';');
        if (!stmt) this.error('unexpected token {peek}, not allowed at the root level');
        block.push(stmt);
      }
      Parser.cache.set(this.hash, block);
    }
    return block;
  },
复制代码

打印结果:

{
  "__type": "Ident",
  "name": "clear",
  "val": {
    "__type": "Function",
    "name": "clear",
    "lineno": 2,
    "column": 8,
    "params": {
      "__type": "Params",
      "nodes": [
        {
          "__type": "Ident",
          "name": "n",
          "val": {
            "__type": "Null"
          },
          "mixin": false,
          "lineno": 2,
          "column": 7
        }
      ],
      "lineno": 2,
      "column": 1
    },
    "block": {
      "__type": "Block",
      "scope": true,
      "lineno": 2,
      "column": 8,
      "nodes": [
        {
          "__type": "Property",
          "segments": [
            {
              "__type": "Ident",
              "name": "zoom",
              "val": {
                "__type": "Null"
              },
              "mixin": false,
              "lineno": 3,
              "column": 3
            }
          ],
          "lineno": 3,
          "column": 3,
          "expr": {
            "__type": "Expression",
            "lineno": 3,
            "column": 9,
            "nodes": [
              {
                "__type": "Unit",
                "val": 1,
                "lineno": 3,
                "column": 9
              }
            ]
          }
        }
      ]
    }
  },
  "mixin": false,
  "lineno": 3,
  "column": 10
}
复制代码

可以发现和整棵ast树中对应的节点是一致的。

我们也能发现mixin节点的特性,所以我们在每次statement解析后收集它:

if (stmt?.val?.__type == "Function") {
          const mixinName = stmt?.val?.name
          global.Mixins || (global.Mixins = new Set())
          global.Mixins.add(mixinName)
        }
        console.log([...global.Mixins])
复制代码

一运行,结果都是空的,咋回事呢?通过debug我们发现,其实它的节点长这样:

{  
    "name":"clear",  
    "val":{  
        "name":"clear",  
        "nodeName":"function"  
    }  
}
复制代码

仔细一番查看发现是这些节点都重写了原型上的toJSON方法,导致打印出来的节点有所差异,可以在node_modules/stylus/lib/nodes查看这些节点。

所以我们需要修改代码:

if (stmt?.val?.nodeName == "function") {
  //...
}
复制代码

再次运行,可以发现其正确收集到了所有的mixin:

['clear']
复制代码

我们需要从这个集合中挑出和css属性同名的mixin。

接下来需要找到属性是否包含mixin,需要在parserindent中的atrule情况判断:

case 'atrule':
const p = this.property();
// 检测所有的属性是否包含指定的Mixin
const Mixins = ['clear']
let currentMixin = [];
if (Array.isArray(p.segments) && p.segments.some(e => {
  currentMixin = Mixins.filter(mixin => mixin == e.name)
  return currentMixin.length
})) {
  // 输出当前文件位置和 currentMixin
  }

}
return p
复制代码

那么问题来了,如何获取当前文件位置呢?在Parser遍历节点的过程中并没有上下文,无法自上而下传递信息,我想到的一个方法是用全局属性,在读取文件的时候把文件名挂载到global.DIR属性上,但是这样子必须确保文件的IO操作是同步的,所以我将源码中的文件API从异步改成了同步,比如fs.readFile改成fs.readFileSync等。

在文件读取的入口bin/convertStylus.js中的function convertStylusfs.readFileSync后记录当前文件的位置:global.DIR = input;

回到我们节点属性的判断:

if (Array.isArray(p.segments) && p.segments.some(e => {
  currentMixin = Mixins.filter(mixin => mixin == e.name)
  return currentMixin.length
})) {
    // 输出当前文件位置和 currentMixin
	console.log(`${global.DIRR} mixins: ${currentMixin} \n`)
  }

}
复制代码

运行之后,成功获取到每个和css属性同名mixin的引用位置,可以手动去修改它。

插件去括号的问题

在一桶猛如虎的操作后,接着又发现了一个样式问题,在浏览器元素审查后发现这个报错:

transform: translate(-50%, -50%) scale(10/12, 10/12);

除法符号没有计算成功,查看代码:

转译前:

transform: scale((11 / 12), (11 / 12))
复制代码

转移后:

transform: scale(11 / 12, 11 / 12);
复制代码

唉我括号呢?

这回我们在插件的代码中去寻找解决方法,因为我发现lib/index.js中的visitNode()会遍历每个节点,在这里寻找stylus AST中有/计算符号的节点:

  if(node.__type== 'BinOp'){
    if(node.op && node.op == "/"){
        console.log(global.DIR)
    }
  }
复制代码

然后手动去把括号加上。

总结

总结一下遇到的3个问题:样式穿透选择器只能转译成/deep/是有问题的,应该提供一个可选项让用户选择什么样的选择器;Mixin签名差异、计算表达式和去括号的操作有明显的bug;而CSS属性同名的Mixin问题我觉得比较特殊,因为我们的开发小伙伴当初应该是为了解决1px的问题而做的,虽然这种做法极其不推荐,但是插件在这方面还是有优化空间的。

一开始没有想到会踩这些坑,早知道如此,就在一开始从AST的角度去解决问题,而不是这种半自动化的操作。如果时间充足,倒是想给作者提一下PR,但是限于时间关系,只能止步于此。

最后还是感谢sylus-convert这个插件给我们的业务带来的便利。

猜你喜欢

转载自juejin.im/post/7097491392854753287