手把手教你制作前端编译插件系列(1) - 如何制作一个运行时的pxTovw插件

92 阅读6分钟

前置知识

文章以babel-loader plugins制作插件为例

对babel-loader不熟悉的可以点击 babel-loader 如何工作? 什么是babel-loader插件? babel-loader插件可以干什么? 如何制作一个babel-loader插件?

开门见山

在移动端项目开发过程中我们一般会用到postcss将px像素单位转换成vw单位, 但是这种方法有个缺点他不能转换诸如像style属性 这种运行时的px单位, 一般会自定义一个工具函数去实现, 例如:

const App = ()=>{
return <div style={{ width:px2vw(32), height:px2vw(32) }}> app </div>
}

可见这种方式比较繁琐, 那么有没有更方便的办法呢? 我们可以制作一个更顶层的style2vw工具函数去实现, 比如:

//将style的px属性转换成vw像素
const style2vw = (styleObj) => (norm=750) => {
  const numberFloor = (num) => {
    return Number((Math.floor(num * 10000) / 10000).toFixed(4))
  }

  const px2vw = (d) => {
    if (typeof d === 'number' && Number(d) === 0) return '0vw'
    if (!d) return ''
    if (typeof d === 'string' && d.indexOf('px') > -1) {
      return numberFloor(Number(d.split('px')[0]) / norm) * 100 + 'vw'
    }
    if (typeof d === 'number') {
      const m = numberFloor(d / norm)
      return m * 100 + 'vw'
    }
    return d
  }
  const newObj = {}
  Object.keys(styleObj).forEach((key) => {
    newObj[key] = px2vw(obj[key])
  })
  return newObj
}

const App = ()=>{
return <div style={style2vw({ width:32, height:32 })}> app </div>
}

为了避免错误的转换的还可以添加一个条件参数,转换符合添加的属性


const style2vw =
  (obj, includesKey = []) =>
  (norm) => {
    const numberFloor = (num) => {
      return Number((Math.floor(num * 10000) / 10000).toFixed(4))
    }

    const px2vw = (d) => {
      if (typeof d === 'number' && Number(d) === 0) return '0vw'
      if (!d) return ''
      if (typeof d === 'string' && d.indexOf('px') > -1) {
        return numberFloor(Number(d.split('px')[0]) / norm) * 100 + 'vw'
      }
      if (typeof d === 'number') {
        const m = numberFloor(d / norm)
        return m * 100 + 'vw'
      }
      return d
    }
    const newObj = {}
    Object.keys(obj).forEach((key) => {
      if (includesKey.length && includesKey.includes(key)) {
        newObj[key] = px2vw(obj[key])
        return
      }

      if (includesKey.length === 0) {
        newObj[key] = px2vw(obj[key])
      }
    })
    // console.log('key', newObj)
    return newObj
  }
const App = ()=>{
return <div style={style2vw({ width:32, height:32 },['width','height'])}> app </div>
}

好了,但是如果每个组件都需要调用一次style2vw, 本质上对解放生产力也没有太大帮助, 我们可以利用编译工具自己制作一个编译插件自动帮我们注入工具函数,岂不美哉!

通过翻阅babel文档,得知webpack-babel-loader允许自定义插件, 语法如下:

 {
           loader: 'babel-loader',
           options: {
              targets: {
                esmodules: true
              },
              plugins: [
               function myplugin(){
				return {
				 visitor:{
					Program(path){}
					JSXAttribute(path){}
				}
      			}
               }
              ],
            }
}

这里的语法稍微解释一下,plugins接收一个函数,在babel编译时会自动运行这个函数, Program是入口函数,可以理解为文件进入时会调用这个入口,JSXAttribute 是一个AST(抽象语法树)节点类型, 文件中的所有JSX属性都会进入这个入口. 因为我们要通过jsx的style属性去注入工具函数,所以这个AST节点类型是相当有作用的.

更具体的babel-plugins接口解释可以查阅官方文档

好,有关的前置铺垫基本结束了,那么如何去实现这样的一种插件呢?

思索了片刻,最简单的方法是这样的:

  1. 检查组件文件是否引入style2vw工具函数, 没有引入就自动导入, 引入了就跳过(防止重复引入相同的文件引发报错)

  2. 检查组件是否有style属性

  3. 如果组件有style属性,就为style属性插入style2vw函数

但是这样做很明显有个问题,会造成资源浪费,因为不一定是每一个组件都需要做运行时转换, 但是如果通过语法上下文去判断是否要注入style2vw的函数也似乎是很困难的事..

这个时候该怎么办呢?

似乎是个头疼的问题,但我们可以借用条件编译的思路,去一条预处理器指令, 让编译器根据这条规则去进行对应的转译, 比如我们可以在style属性前面声明一个/s2v/ 的注释声明,编译器碰到这个声明就会自动注入工具函数,比如:

 <p /*s2v*/ style={{ width:32, height:32}}></p>

但是如果一个文件内需要转译的像素属性比较多似乎也是一件麻烦的事情,我们可以添加另外一条预处理指令/s2v:file/ 告诉编译器如果碰到这个声明会自动转译当前文件的所有style属性

/*s2v:file*/
 <p style={{ width: 22,}}></p>
 <p style={{ width: 22,}}></p>
 <p style={{ width: 22,}}></p>

好啦,这样主要的几点矛盾都解决了,我们再来整理一下这个编译插件的实现思路:

  1. 检查组件文件是否引入style2vw工具函数, 没有引入就自动导入, 引入了就跳过(防止重复引入相同的文件引发报错)

  2. 识别文件或者组件是否具有预处理器指令 /s2v/ , /s2v:file/

  3. 如果存在预处理指令,就为组件插入style2vw函数

第1步骤, 实现代码:

  Program(path) {
          //检查是否有符合条件的指令
          canUse =
            path.node.body[0].leadingComments?.some(
              ({ type, value }) => type === 'CommentBlock' && value.trim().includes('s2v:file')
            ) || all

          // 检查是否已经存在导入
          let hasDecimalImport = false
          path.traverse({
            ImportDeclaration(path) {
              if (path.node.source.value === importPath) {
                hasDecimalImport = true
                path.stop()
              }
            }
          })
           
          //如果没有则自动导入
          if (!hasDecimalImport) {
            const importDeclaration = t.importDeclaration(
              [t.importDefaultSpecifier(t.identifier(tool))],
              t.stringLiteral(importPath)
            )
            path.node.body.unshift(importDeclaration)
          }
        },

第2,3步实现代码:


  JSXAttribute(path) {
          try {
          //检查条件指令是否存在
            if (
              !(
                canUse ||
                path.node.value?.expression?.leadingComments?.some(
                  ({ type, value }) => type === 'CommentBlock' && value.trim().includes('s2v')
                )
              )
            ) {
              return
            }
            //检查Jsx Attribute 是否是style属性
            if (path.node.name.name === 'style') {
              const styleValue = path.node.value

              //记录style属性的AST表达式
              const s2vCallExpression = t.callExpression(
                t.callExpression(t.identifier(tool), [styleValue.expression]),
                [t.numericLiteral(norm)]
              )

              // 将 style 的ast表达式替换成 s2v(style) 的形式
              path.node.value = t.jsxExpressionContainer(s2vCallExpression)
            }
          } catch (error) {
           //错误处理
            console.warn(path.buildCodeFrameError('style 语法错误').message)
          }
        }

完整代码

/**
 * jsx style标签转px2vw
 * 块级注释 s2v:file 文件头部对整个文件的style属性做转换
 * 块级注释 s2v 对当前style属性做转换
 * @norm number 设计稿标准尺寸 750
 * @all boolean 默认转换对所有style属性开启
 * @example
 * stylePxToVw()
 */
module.exports = ({ norm = 750, all = false } = {}) => {
  console.log('style px转换:            ', '/*s2v*/    ', 'example: <div style={ /*s2v*/ style} />')
  console.log('style px转换(所有文件):  ', '/*s2v:file*/', 'example: 注释于文件顶部')

  return function ({ types: t }) {
    let canUse
    let importPath = 'hy-babel-plugins/plugins/stylePxToVw/s2v'
    let tool = '$$__hy__s2v__$$'

    return {
      visitor: {
        Program(path) {
          canUse =
            path.node.body[0].leadingComments?.some(
              ({ type, value }) => type === 'CommentBlock' && value.trim().includes('s2v:file')
            ) || all

          // 检查是否已经有 Decimal 的导入
          let hasDecimalImport = false
          path.traverse({
            ImportDeclaration(path) {
              if (path.node.source.value === importPath) {
                hasDecimalImport = true
                path.stop()
              }
            }
          })

          if (!hasDecimalImport) {
            const importDeclaration = t.importDeclaration(
              [t.importDefaultSpecifier(t.identifier(tool))],
              t.stringLiteral(importPath)
            )
            path.node.body.unshift(importDeclaration)
          }
        },
        JSXAttribute(path) {
          try {
            if (
              !(
                canUse ||
                path.node.value?.expression?.leadingComments?.some(
                  ({ type, value }) => type === 'CommentBlock' && value.trim().includes('s2v')
                )
              )
            ) {
              return
            }

            if (path.node.name.name === 'style') {
              const styleValue = path.node.value

              // Create a new call expression s2v(style)
              const s2vCallExpression = t.callExpression(
                t.callExpression(t.identifier(tool), [styleValue.expression]),
                [t.numericLiteral(norm)]
              )

              // Replace the original style value with s2v(style)
              path.node.value = t.jsxExpressionContainer(s2vCallExpression)
            }
          } catch (error) {
            console.warn(path.buildCodeFrameError('style 语法错误').message)
          }
        }
      }
    }
  }
}

最后在webpack中引入

 {
           loader: 'babel-loader',
           options: {
              targets: {
                esmodules: true
              },
              plugins: [
              stylePxToVw(),
              ],
            }
}

在写一个测试用例试试

const style = {
  width: 22,
  width: '22',
  wdith: pp
}

const main = () => {
  return (
    <div style={/*s2v*/ style}>
      <p style={/*s2v*/ {}}></p>
      <p /*s2v*/ style></p>
      <p style={/*s2v*/ { width: 22, width: '22', wdith: pp }}></p>
      <p style={{ ...style }}></p>
      <p style={style.style}></p>
      <p style={{ ...style.style }}></p>
      <p style={object.assgin({}, style)}></p>
      <p style={{ ...merge(style.style, {}, {}) }}></p>
      <p style={merge(style.style, {}, {})}></p>
      <p style={_.merge(style.style, {}, {})}></p>
      <p style={{ width: 22, width: '22', wdith: pp, ...merge(style.style, {}, {}) }}></p>
      <p style={{ width: 22, width: '22', wdith: pp, ..._.merge(style.style, {}, {}) }}></p>
    </div>
  )
}

经过测试基本达到了诉求目标

文章源码

github.com/hanyaxxx/hy… 可根据源码调试