公司的前端项目基本上都是用的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:
```stylus
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,需要在parser
的indent
中的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 convertStylus
中fs.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
这个插件给我们的业务带来的便利。