最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • Postcss了解一下

    正文概述 掘金(bugmaker)   2021-05-21   796

    工作中用到的框架 css 处理有以下问题,需要用 postcss 做些自动处理。

    • 同名 class 后者会覆盖前者:.a{color: #fff} .a{background: #fff},后者生效
    • 最多嵌套两层:.a .b .c {}不生效

    通过本文学习了解什么是 postcss,以及如何通过 postcss 做一些工作。

    简介

    从其名字 postcss 可以看出早期是被当做后处理器的。也就是处理less/sass 编译后的 css。最常用的插件就是 autoprefixer,根据浏览器版本添加兼容前缀。

    Postcss了解一下

    postcss 像 babel 一样会把 style 转成 ast,经过一系列插件转换,再将转换后的 as t生成新的 style。随着发展,用后处理器形容postcss 已经不合适了。目前可以使用 postcss-sass/postcss-less 对 less/sass 代码进行转化(将 less/sass 转化为 less/sass,而不是直接转化为 css),也可以使用 precss 代替 sass(感觉还不太成熟)。

    Postcss了解一下

    所以目前推荐的还是 postcss 和 less/sass 结合使用,在 webpack 配置中,postcss-loader 要写在 sass-loader/less-loader 前面。

    module.exports = {
        module: {
            rules: [
                {
                    test: /\.(css|less)$/i,
                    use: ['style-loader', 'css-loader', 'postcss-loader', 'less-loader'],
                },
            ]
        }
    }
    

    关于更多 postcss 的用途,可以参考 github.com/postcss/pos…

    工作流程

    Postcss了解一下

    大致步骤:

    • 将 CSS 字符串生成 Tokens
    • 将 Tokens 通过规则生成 AST 树
    • 将 AST 树传给插件进行处理
    • 将处理后的 AST 树生成新的css资源(包括css字符串、sourceMap等)

    即如图所示的步骤: CSS Input → Tokenizer → Parser → AST → Plugins → Stringifier 举个?:

    @import url('./default.css');
    body {
      padding: 0;
      /* margin: 0; */
    }
    

    1. input

    '@import url('./default.css');\nbody {\n  padding: 0;\n  /* margin: 0; */\n}\n'
    

    2. tokenizer

    tokenizer 包括以下几个方法:

    • back:back方法会设置 nextToken 的下次调用的返回值。
    • nextToken:获取下一个 token。
    • endOfFile:判断文件是否结束。
    • position:获取当前 token 的位置。
    // tokenize.js的nextToken方法 简化代码
    function nextToken(opts) {
        // 如果之前调用了back方法,下一次调用nextToken将会返回back方法设置的token
        if (returned.length) return returned.pop()
        code = css.charCodeAt(pos)
        // 判断每一个字符
        switch (code) { 
            case xxx: 
            break;
        }
    
        pos++
        return currentToken
      }
    

    nextToken 方法判断每一个字符,并生成如下类型的 token:

    space:

    • \n:换行
    • :空格
    • \f :换页
    • \r:回车
    • \t:水平制表符
      // 空格、换行、制表符、回车等,都被当做space类型token
      case NEWLINE:
      case SPACE: 
      {
        next = pos
        // 循环,将连续的空格、换行、回车等作为一个token
        do {
          next += 1
          code = css.charCodeAt(next)
        } while (
           // 如果是
        )
        // 截取token的值
        currentToken = ['space', css.slice(pos, next)]
        pos = next - 1
        break
      }
    

    string:

    • ':单引号
    • ":双引号
     // 单引号,双引号之间的内容会被当成string类型token
      case SINGLE_QUOTE:
      case DOUBLE_QUOTE: {
        quote = code === SINGLE_QUOTE ? "'" : '"'
        next = pos
        do {
          next = css.indexOf(quote, next + 1)
          if (next === -1) {
            if (ignore || ignoreUnclosed) {
              next = pos + 1
              break
            } else {
              unclosed('string')
            }
          }
        } while (escaped)
    
        currentToken = ['string', css.slice(pos, next + 1), pos, next]
        pos = next
        break
      }
    

    at-word:@ 和其后面连着的字符会被当成 at-wrod 类型 token @*:at符

    case AT: {
       currentToken = ['at-word', css.slice(pos, next + 1), pos, next]
       pos = next
       break
     }
    

    [和]:中括号

    ):右括号

    {和}:大括号

    ;:分号

    ::冒号

    都是独立的 token 类型。

    // []{}:;)等都是独立的token类型
    case OPEN_SQUARE:
    case CLOSE_SQUARE:
    case OPEN_CURLY:
    case CLOSE_CURLY:
    case COLON:
    case SEMICOLON:
    case CLOSE_PARENTHESES: {
        let controlChar = String.fromCharCode(code)
        currentToken = [controlChar, controlChar, pos]
        break
    }
    

    (和brackets

    • url():url() 值没有单引号、双引号包裹的,当做brackets类型 token
    • url(''):如果没有右括号),或者匹配到正则,当成(类型token,比如url('')
    • var(--main-color):否则当成 brackets 类型 token,比如var(--main-color)
    // 左括号特殊处理
    case OPEN_PARENTHESES: {
    prev = buffer.length ? buffer.pop()[1] : ''
    n = css.charCodeAt(pos + 1)
        // url()值没有单引号、双引号包裹的,当做brackets类型token
        if (
          prev === 'url' &&
          n !== SINGLE_QUOTE &&
          n !== DOUBLE_QUOTE &&
        ) {
          next = pos
          do {
            escaped = false
            next = css.indexOf(')', next + 1)
            if (next === -1) {
              if (ignore || ignoreUnclosed) {
                next = pos
                break
              } else {
                unclosed('bracket')
              }
            }      
          } while (escaped)
          currentToken = ['brackets', css.slice(pos, next + 1), pos, next]
          pos = next
        } else {
          next = css.indexOf(')', pos + 1)
          content = css.slice(pos, next + 1)
          // 如果没有右括号),或者匹配到正则,当成(类型token,比如url('')
          if (next === -1 || RE_BAD_BRACKET.test(content)) {
            currentToken = ['(', '(', pos]
          } else {
            // 否则当成brackets类型token,比如var(--main-color)
            currentToken = ['brackets', content, pos, next]
            pos = next
          }
        }
        break
    }
    

    comment: 默认会被当成 comment 类型和 word 类型 token

    • /:斜线
    • *:通配符

    word:

    • \:反斜线
    default: {
        if (code === SLASH && css.charCodeAt(pos + 1) === ASTERISK) {
          next = css.indexOf('*/', pos + 2) + 1
          if (next === 0) {
            if (ignore || ignoreUnclosed) {
              next = css.length
            } else {
              unclosed('comment')
            }
          }
    
          currentToken = ['comment', css.slice(pos, next + 1), pos, next]
          pos = next
        } else {
          RE_WORD_END.lastIndex = pos + 1
          RE_WORD_END.test(css)
          if (RE_WORD_END.lastIndex === 0) {
            next = css.length - 1
          } else {
            next = RE_WORD_END.lastIndex - 2
          }
    
          currentToken = ['word', css.slice(pos, next + 1), pos, next]
          buffer.push(currentToken)
          pos = next
        }
    
        break
    }
    

    经过 tokenizer 处理成如下的 tokens:

    [ 'at-word', '@import', 0, 6 ]
    [ 'space', ' ' ]
    [ 'word', 'url', 8, 10 ]
    [ '(', '(', 11 ]
    [ 'string', "'./default.css'", 12, 26 ]
    [ ')', ')', 27 ]
    [ ';', ';', 28 ]
    [ 'space', '\n' ]
    [ 'word', 'body', 30, 33 ]
    [ 'space', ' ' ]
    [ '{', '{', 35 ]
    [ 'space', '\n  ' ]
    [ 'word', 'padding', 39, 45 ]
    [ ':', ':', 46 ]
    [ 'space', ' ' ]
    [ 'word', '0', 48, 48 ]
    [ ';', ';', 49 ]
    [ 'space', '\n  ' ]
    [ 'comment', '/* margin: 0; */', 53, 68 ]
    [ 'space', '\n' ]
    [ '}', '}', 70 ]
    [ 'space', '\n' ]
    

    可以看到,token是一个数组,以第一个token为例,数据结构如下

    [
        'at-word', // 类型
        '@import', // 值
        0, // 起始位置
        6   // 终止位置
    ]
    

    3. parser

    parser 会循环调用 tokenizer 的 nextToken 方法,直到文件结束。在循环过程中使用一些算法和条件判断去创建节点然后构建 AST。上面例子生成的 AST 如下:

    Postcss了解一下

    3.1 节点

    Node 和 Container 节点的基础类,其中 Container 继承自 Node。AST 由下面几种节点组成

    • Root: 继承自 Container。AST 的根节点,代表整个 css 文件
    • AtRule: 继承自 Container。以 @ 开头的语句,核心属性为 params,例如:@import url('./default.css'),@keyframes shaking {}。params 为url('./default.css')
    • Rule: 继承自 Container。带有声明的选择器,核心属性为 selector,例如:body {},selector为body
    • Declaration:继承自 Node。声明,是一个键值对,核心属性为 prop,value,例如:padding: 0,prop为padding,value为0
    • Comment: 继承自 Node。标准的注释/* 注释 */,如图所示 text 为margin: 0;

    节点包括一些通用属性

    • type:节点类型

    • parent:父节点

    • source:存储节点的资源信息,计算 sourcemap

      • input:输入
      • start:节点的起始位置
      • end:节点的终止位置
    • raws:存储节点的附加符号,分号、空格、注释等,在 stringify 过程中会拼接这些附加符号

      通用:

      • before:The space symbols before the node. It also stores * and _ symbols before the declaration (IE hack).

      作用于Rule:

      • after:The space symbols after the last child of the node to the end of the node. 最后一个子节点和节点末尾之间的space符号
      • between:The symbols between the selector and { for rules 。selector和{之间的符号
      • semicolon:Contains true if the last child has an (optional) semicolon.最后一个子节点有;则为true
      • ownSemicolon: Contains true if there is semicolon after rule.如果rule后面有;则为true

      作用于Comment

      • left:The space symbols between /* and the comment’s text. /* 和注释内容之间的space符号
      • right:The space symbols between the comment’s text. */和注释内容之间的space符号 作用于Declaration
      • important:The content of the important statement. 是否是important
      • value: Declaration value with comments. 带有注释的声明值。

    节点有各自的API,具体可以看PostCSS API

    3.2 生成过程

    class Parser {  
      parse() {
        let token
        while (!this.tokenizer.endOfFile()) {
          token = this.tokenizer.nextToken()
          switch (token[0]) {
            case 'space':
              this.spaces += token[1]
              break
            case ';':
              this.freeSemicolon(token)
              break
            case '}':
              this.end(token)
              break
            case 'comment':
              this.comment(token)
              break
            case 'at-word':
              this.atrule(token)
              break
            case '{':
              this.emptyRule(token)
              break
            default:
              this.other(token)
              break
          }
        }
        this.endFile()
      }
    }
    

    先创建 root 节点,并将 current(当前节点)设置为 root,使用tokens 变量存储已经遍历但还未使用过的 token。

    • 遇到 at-rule token,创建 atRule 节点
    • 遇到 { token,创建 rule 节点
      • 将 tokens 中存储的token生成 rule 的 selector 属性
      • 将 current 设置为 rule 节点
      • 将 rule 节点 push 到 current 的 nodes 中
    • 遇到 ; token,创建 decl 节点。有一种特殊情况:declaration 是以;分隔的,如果是最后一条规则,可以不带;,比如.a{color: blue},
      • 将 decl 节点 push 到 current 的 nodes 中。
    • 遇到 comment token,就创建comment节点
    • 遇到 } token,认为当规则结束
      • 将 current 设置为 current.parent(当前节点的父节点)

    具体过程可以看源码: github.com/postcss/pos…

    用到的算法:

    4. plugins

    然后会调用plugins修改Parser得到的AST树。plugins的执行在lazy-result.js中。

    class LazyResult {
      get [Symbol.toStringTag]() {
        return 'LazyResult'
      }
      get processor() {
        return this.result.processor
      }
      get opts() {
        return this.result.opts
      }
      // 获取css
      get css() {
        return this.stringify().css
      }
      get content() {
        return this.stringify().content
      }
      get map() {
        return this.stringify().map
      }
      // 获取root
      get root() {
        return this.sync().root
      }
      get messages() {
        return this.sync().messages
      }
    }
    

    在访问result.css、result.map、result.root时均会执行对应的函数。

    stringify() {
        if (this.error) throw this.error
        if (this.stringified) return this.result
        this.stringified = true
        // 同步执行插件
        this.sync()
    
        let opts = this.result.opts
        let str = stringify
        if (opts.syntax) str = opts.syntax.stringify
        if (opts.stringifier) str = opts.stringifier
        if (str.stringify) str = str.stringify
        // 生成map和css
        let map = new MapGenerator(str, this.result.root, this.result.opts)
        let data = map.generate()
        this.result.css = data[0]
        this.result.map = data[1]
    
        return this.result
     }
    

    在访问result.css时会先同步执行插件,然后用处理后的AST去生成css和sourcemap

    sync() {
        if (this.error) throw this.error
        if (this.processed) return this.result
        this.processed = true
    
        if (this.processing) {
          throw this.getAsyncError()
        }
    
        for (let plugin of this.plugins) {
          let promise = this.runOnRoot(plugin)
          if (isPromise(promise)) {
            throw this.getAsyncError()
          }
        }
        // 先收集访问器
        this.prepareVisitors()
        if (this.hasListener) {
          let root = this.result.root
          // 如果root脏了,在root节点重新执行插件
          while (!root[isClean]) {
            root[isClean] = true
            this.walkSync(root)
          }
          if (this.listeners.OnceExit) {
            this.visitSync(this.listeners.OnceExit, root)
          }
        }
    
        return this.result
      }
    

    新版插件支持访问器,访问器有两种类型:Enter 和 Exit。Once,Root,AtRule,Rule,Declaration,Comment等会在处理子节点之前调用,OnceExit,RootExit,AtRuleExit...等会在所有子节点处理完成后调用。其中 Declaration 和 AtRule 支持属性的监听,比如:

    module.exports = (opts = {}) => {
        Declaration: {
            color: decl => {}
            '*': decl => {}
        },
        AtRule: {
            media: atRule => {}
        }
        Rule(){}
    }
    

    prepareVisitors 方法会搜集这些访问器,添加到 listeners 中,以上面的代码为例,会添加Declaration-color,Declaration*,AtRule-media,Rule等访问器。

    prepareVisitors() {
        this.listeners = {}
        let add = (plugin, type, cb) => {
          if (!this.listeners[type]) this.listeners[type] = []
          this.listeners[type].push([plugin, cb])
        }
        for (let plugin of this.plugins) {
          if (typeof plugin === 'object') {
            for (let event in plugin) {
              if (!NOT_VISITORS[event]) {
                if (typeof plugin[event] === 'object') {
                  for (let filter in plugin[event]) {
                    if (filter === '*') {
                      add(plugin, event, plugin[event][filter])
                    } else {
                      add(
                        plugin,
                        event + '-' + filter.toLowerCase(),
                        plugin[event][filter]
                      )
                    }
                  }
                } else if (typeof plugin[event] === 'function') {
                  add(plugin, event, plugin[event])
                }
              }
            }
          }
        }
        this.hasListener = Object.keys(this.listeners).length > 0
    }
    

    然后在 walkSync 过程中,会判断该节点可以拥有的访问器类型,如果是 CHILDREN 则递归调用子节点,如果是其他可执行的访问器比如 Rule,则会执行访问器。

     walkSync(node) {
        node[isClean] = true
        let events = getEvents(node)
        for (let event of events) {
          if (event === CHILDREN) {
            if (node.nodes) {
              node.each(child => {
                if (!child[isClean]) this.walkSync(child)
              })
            }
          } else {
            let visitors = this.listeners[event]
            if (visitors) {
              if (this.visitSync(visitors, node.toProxy())) return
            }
          }
        }
    }
    

    如果在节点上执行一些有副作用的操作,比如append、prepend、remove、insertBefore、insertAfter等,会循环向上标记副作用node[isClean] = false,直到root[isClean] = false。会导致再次执行插件,甚至会导致死循环。

    5. stringifier

    stringifier 从 root 开始,层序遍历 AST 树,根据节点类型,拼接节点的数据为字符串。

    // stringifier.js 简化代码
    class Stringifier {
      constructor(builder) {
        this.builder = builder
      }
      stringify(node, semicolon) {
        // 调用对应类型节点
        this[node.type](node, semicolon)
      }
      // root节点处理
      root(node) {
        this.body(node)
        if (node.raws.after) this.builder(node.raws.after)
      }
      // root节点子节点处理
       body(node) {
        let last = node.nodes.length - 1
        while (last > 0) {
          if (node.nodes[last].type !== 'comment') break
          last -= 1
        }
    
        let semicolon = this.raw(node, 'semicolon')
        for (let i = 0; i < node.nodes.length; i++) {
          let child = node.nodes[i]
          let before = this.raw(child, 'before')
          if (before) this.builder(before)
          this.stringify(child, last !== i || semicolon)
        }
      }
      // comment类型节点拼接
      comment(node) {}
      // decl类型节点拼接
      decl(node, semicolon) {}
      // rule类型节点拼接
      rule(node) {}
      // at-rule类型节点拼接
      atrule(node, semicolon) {}
      // block节点处理,rule,at-rule(@media等)
      block(node, start){}
      // raw信息处理
      raw(){}
    }
    

    root、body、comment、decl、rule、atrule、block、raw等是不同类型节点、信息的字符串拼接函数。 整个过程从root开始,做层序遍历,root→body→rule/atrule/comment/decl,然后通过 builder 拼接字符串。builder 是一个拼接函数:

    const builder = (str, node, type) => {
        this.css += str
    }
    

    插件plugins

    1. 老写法

    const postcss = require('postcss');
    
    module.exports = postcss.plugin('postcss-plugin-old', function (opts) {
      return function (root) {
        root.walkRules((rule) => {
          if (rule.selector === 'body') {
            rule.append(postcss.decl({ prop: 'margin', value: '0' }));
            rule.append(postcss.decl({ prop: 'font-size', value: '14px' }));
          }
        });
      };
    });
    

    老写法需要引入 postcss,所以插件需要将 postcss 设置为 peerDependence,然后使用 postcss 的 api 去操作 AST。

    2. 新写法

    // 使用symbol标记处理过的节点
    const processd = Symbol('processed');
    module.exports = (opts = {}) => {
      return {
        postcssPlugin: 'postcss-plugin-new',  
        Once() {},
        OnceExit(root) {
          root.walkDecls((decl) => {
            // 删除节点
            if (decl.prop === 'color') {
              decl.value = '#ee3';
            }
          });
        },
        Rule(rule, { Declaration }) {
          if (!rule[processd]) {
            if (rule.selector === 'body') {
              rule.append(new Declaration({ prop: 'color', value: '#333' }));
            }
            rule[processd] = true;
          }
        },
        Declaration: {
          padding: (decl) => {},
          margin: (decl) => {
            if (decl.value === '0') {
              decl.value = '10px';
            }
          },
        },
        DeclarationExit() {},
        prepare(result){
            const variables = {};
            return {
                Declaration(){}
                OnceExit(){}
            }
        }
      };
    };
    module.exports.postcss = true;
    

    新写法不再需要引入 postcss,而且新增了访问器(Visitor)。

    • 访问器有 Enter 和 Exit 两种,比如 Declaration 会在访问 decl 节点时执行,DeclarationExit 会在所有 Declaration 访问器处理完之后再处理。
    • 可以利用 prepare() 动态生成访问器。
    • 访问器的第一个参数是访问的节点 node,可以直接调用 node 的方法进行操作。
    • 访问器的第二个参数是{ ...postcss, result: this.result, postcss },方便调用 postcss 上的方法。

    更多可以参考官方文档 write-a-plugin

    语法syntax

    postcss-less 和 postcss-scss 都属于 syntax,只能识别这种语法,然后进行转换,并不会编译生成css

    内部实现也都是继承 Tokenizer 和 Parser 类,并重写内部部分方法。

    比如 css 是不支持//注释得,如果我们不指定 syntax 为 postcss-scss,postcss会因为不识别//而报错CssSyntaxError: Unknown word,如果写一个 syntax 支持这种语法呢?

    1. 首先 tokenizer 需要识别//为 comment 类型 token
     function nextToken(){
         // ...
         if(){
         } else if (code === SLASH && n === SLASH) {
          RE_NEW_LINE.lastIndex = pos + 1
          RE_NEW_LINE.test(css)
          if (RE_NEW_LINE.lastIndex === 0) {
            next = css.length - 1
          } else {
            next = RE_NEW_LINE.lastIndex - 2
          }
        
          content = css.slice(pos, next + 1)
          // inline表示是//注释
          currentToken = ['comment', content, pos, next, 'inline']
        
          pos = next
        }
    }
    
    1. 然后 parser 需要将其构建为 node 节点,并存储 source,raws 等信息。
    class ProParser extends Parser{
        comment (token) {
            if (token[4] === 'inline') {
                let node = new Comment()
                this.init(node, token[2])
                node.raws.inline = true
                let pos = this.input.fromOffset(token[3])
                node.source.end = { offset: token[3], line: pos.line, column: pos.col       }
                
                let text = token[1].slice(2)
                if (/^\s*$/.test(text)) {
                    node.text = ''
                    node.raws.left = text
                    node.raws.right = ''
                } else {
                    let match = text.match(/^(\s*)([^]*\S)(\s*)$/)
                    let fixed = match[2].replace(/(\*\/|\/\*)/g, '*//*')
                    node.text = fixed
                    node.raws.left = match[1]
                    node.raws.right = match[3]
                    node.raws.text = match[2]
                }
            } else {
                super.comment(token)
            }
        }
    }
    
    1. 最后 stringifier 需要将其拼接为字符串
     class ProStringifier extends Stringifier {
         comment (node) {
            let left = this.raw(node, 'left', 'commentLeft')
            let right = this.raw(node, 'right', 'commentRight')
        
            if (node.raws.inline) {
              let text = node.raws.text || node.text
              this.builder('//' + left + text + right, node)
            } else {
              this.builder('/*' + left + node.text + right + '*/', node)
            }
         }
     }
    

    解决开篇

    针对开篇的场景,思路是:

    1. 根据 selector 拆分,比如.a .b{}拆分成.a{},.b{},并将前后同名 selector 的 rule 的 declaration 进行合并。
    2. 对 selector 进行 split(' ')length>2 的进行裁剪处理
    module.exports = (options = {}) => {
      return {
        postcssPlugin: 'postcss-plugin-crop-css',
        Once (root, { postcss }) {
          const selectorRuleMap = new Map()
          root.walkRules((rule) => {
            const { selector } = rule
            const selectorUnits = selector.split(',')
            for (let selectorUnit of selectorUnits) {
              let selectorUnitArr = selectorUnit.split(' ')
              // 选择器超过两层,报错
              if (selectorUnitArr.length > 2) {
                throw rule.error('no more than two nested levels')
              }
              const selectorCrop = selectorUnitArr.join(' ').replace('\n', '')
              const existSelectorRule = selectorRuleMap.get(selectorCrop)
              const nodes = existSelectorRule ? [existSelectorRule.nodes, rule.nodes] : rule.nodes
              const newRule = new postcss.Rule({
                selector: selectorCrop,
                source: rule.source,
                nodes
              })
              selectorRuleMap.set(selectorCrop, newRule)
            }
            rule.remove()
          })
          selectorRuleMap.forEach(selectorRule => {
            root.append(selectorRule)
          })
        }
      }
    }
    
    module.exports.postcss = true
    

    参考

    1. postcss

    广告

    字节跳动游戏发行&小游戏前端组有坑位,简历请发到wangyichen.33@bytedance.com


    起源地下载网 » Postcss了解一下

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元