最新公告
  • 欢迎您光临网站无忧模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 扒一扒 前端跨平台框架Taro/Chamelon/uniApp 多端差异性实现 - 掘金

    正文概述 掘金(妙龄黄花哥)   2021-11-01   746

    预备

    这里先看一下 这三个平台对于跨平台适配的描述

    • Taro
    Taro 的设计初衷就是为了统一跨平台的开发方式,并且已经尽力通过运行时框架、组件、API 去抹平多端差异,但是由于不同的平台之间还是存在一些无法消除的差异,所以为了更好的实现跨平台开发,Taro 中提供了如下的解决方案:
    内置环境变量
    ...
    为了方便大家书写样式跨端的样式代码,添加了样式条件编译的特性。
    
    • Chameleon
    CML 的是多端的上层应用语言,在这样的目标下,用户扩展功能时,保障业务代码和各端通信一致性变得特别重要。
    ...
    以上,跨端很美好,最大风险是可维护性问题。多态协议是 CML 业务层代码和各端底层组件和接口的分界点,CML 会严格“管制”输入输出值的类型和结构,同时会严格检查业务层 JS 代码,避免直接使用某端特有的接口,不允许在公共代码处使用某个端特定的方法,即使这段代码不会执行,例如禁止使用 `window` 、 `wx` 、 `my` 、 `swan` 、  `weex`  等方法。
    
    • uniApp
    uni-app 已将常用的组件、JS API 封装到框架中,开发者按照 uni-app 规范开发即可保证多平台兼容,大部分业务均可直接满足。
    但每个平台有自己的一些特性,因此会存在一些无法跨平台的情况。
    - 大量写 if else,会造成代码执行性能低下和管理混乱。
    - 编译到不同的工程后二次修改,会让后续升级变的很麻烦。
    在 C 语言中,通过 #ifdef、#ifndef 的方式,为 windows、mac 等不同 os 编译不同的代码。  `uni-app`  参考这个思路,为  `uni-app`  提供了条件编译手段,在一个工程里优雅的完成了平台个性化实现。
    

    以上可以看出每个开源跨端框架都不能100%保证用户使用该框架能完全不管兼容性问题,只是帮助开发解决了大部分兼容问题,针对一些平台特性问题难以兼容部分,仍然需要开发者自己来完成, 那他们是如何实现这部分兼容部分的处理的呢,我们就来 扒开外衣,看看本质,由您看看哪家实现最优雅...

    开始

    Taro

    内置环境变量

    process.env.TARO_ENV 用于判断当前编译类型,目前有 weapp / swan / alipay / h5 / rn / tt / qq / quickapp 八个取值,可以通过这个变量来书写对应一些不同环境下的代码,在编译时会将不属于当前编译类型的代码去掉,只保留当前编译类型下的代码,例如想在微信小程序和 H5 端分别引用不同资源

    if (process.env.TARO_ENV === 'weapp') {
      require('path/to/weapp/name')
    } else if (process.env.TARO_ENV === 'h5') {
      require('path/to/h5/name')
    }
    

    这个实现方案,使用过webpack的开发者比较熟悉,实现原理是 使用webpack.DefinePlugin插件 注入到webpack中,在webpack 编译过程中启用 Tree-Shaking 来过滤掉 兼容平台的使用不到的代码。那 Taro 是在哪里处理的呢,我们来看下Taro的源码

    1. 首先我们使用 taro-cli 提供的初始化项目之后,它在package.json 里提供多种平台的编译方式
    "scripts": {
        "build:swan": "taro build --type swan",
        "build:weapp": "taro build --type weapp",
        "build:alipay": "taro build --type alipay",
    	...
      },
    

    可以看到 在 scripts 运行的时候 使用 --type 传入的 TARO_ENV 的值 vscode 打开 Taro 源码中next 分支

    // packages/taro-cli/src/cli.ts
    customCommand('build', kernel, {
                _: args._,
                platform,
                plugin,
                isWatch: Boolean(args.watch),
                ...
              })
    

    taro-cli 将命令行传入的type 使用platform 变量传入给Kernel 处理,Kernel 位于taro-service子包,作为基础服务提供实时的编译工作, Taro 的核心就是 利用 Kernel + 注册插件 + 生命周期钩子函数 的实现方式,灵活的实现了各个不同的命令组合

    1. Kernel 调用 mini-runner 进行build 构建, 将platform 传给 buildAdapter 处理
    //packages/taro-service/src/platform-plugin-base.ts
    /**
       * 准备 mini-runner 参数
       * @param extraOptions 需要额外合入 Options 的配置项
       */
      protected getOptions (extraOptions = {}) {
        const { ctx, config, globalObject, fileType, template } = this
    
        return {
    	       ...
          buildAdapter: config.platform,
          ...
        }
      }
    
      /**
       * 调用 mini-runner 开始编译
       * @param extraOptions 需要额外传入 @tarojs/mini-runner 的配置项
       */
      private async build (extraOptions = {}) {
        this.ctx.onBuildInit?.(this)
        await this.buildTransaction.perform(this.buildImpl, this, extraOptions)
      }
    
      private async buildImpl (extraOptions) {
        const runner = await this.getRunner()
        const options = this.getOptions(Object.assign({
          runtimePath: this.runtimePath,
          taroComponentsPath: this.taroComponentsPath
        }, extraOptions))
        await runner(options)
      }
    

    扒一扒 前端跨平台框架Taro/Chamelon/uniApp 多端差异性实现 - 掘金 这里可以看到 runner 中options 在编译 微信小程序的时候输出的变量 3. taro-mini-runner 中执行 build.config.ts中build方法, 在build里利用 export const getDefinePlugin = pipe(mergeOption, listify, partial(getPlugin, webpack.DefinePlugin)) 引入webpack.DefinePlugin

    export default (appPath: string, mode, config: Partial<IBuildConfig>): any => {
      const chain = getBaseConf(appPath)
      const {
        buildAdapter = PLATFORMS.WEAPP,
        ...
      } = config
      ...
      env.TARO_ENV = JSON.stringify(buildAdapter)
      const runtimeConstants = getRuntimeConstants(runtime)
      const constantsReplaceList = mergeOption([processEnvOption(env), defineConstants, runtimeConstants])
      const entryRes = getEntry({
        sourceDir,
        entry,
        isBuildPlugin
      })
      ...
      plugin.definePlugin = getDefinePlugin([constantsReplaceList])
    
      chain.merge({
        mode,
        devtool: getDevtool(enableSourceMap, sourceMapType),
        entry: entryRes!.entry,
        ...
        plugin,
        optimization: {
          ...
        }
      })
      ...
      return chain
    }
    

    将 buildAdapter 作为env.TARO_ENV 传入到 webpack.DefinePlugin 之后利用 webpack 进行打包,做差异性处理

    样式的条件编译

    以上 webpack.DefinePlugin 可以针对 ts/js 代码进行 Tree-shaking

    • 样式处理上,对于RN的样式处理直接替换整个 css代码
    当在 JS 文件中引用样式文件: `import './index.scss'`  时,RN 平台会找到并引入  `index.rn.scss` ,其他平台会引入: `index.scss` ,方便大家书写跨端样式,更好地兼容 RN。
    

    这里也很好理解,对于RN的替换引入文件的方式,如果我们作为Taro开发者, 在webpack 的 loader 插件中判断方法 对应的scss 文件有无 以 .rn.scss 文件,直接改变引入即可, taro 里是在哪里实现这些操作呢?

    1. 定义css 后缀文件
    // packages/taro-helper/src/constants.ts
    export const CSS_EXT: string[] = ['.css', '.scss', '.sass', '.less', '.styl', '.stylus', '.wxss', '.acss']
    export const JS_EXT: string[] = ['.js', '.jsx']
    export const TS_EXT: string[] = ['.ts', '.tsx']
    
    1. 判断编译平台,优先选择对应平台的 style 文件
    //packages/taro-rn-supporter/src/utils.ts
    // lookup modulePath if the file path exist
    // import './a.scss'; import './app'; import '/app'; import 'app'; import 'C:\\\\app';
    function lookup (modulePath, platform, isDirectory = false) {
      const extensions = ([] as string[]).concat(helper.JS_EXT, helper.TS_EXT, helper.CSS_EXT)
      const omitExtensions = ([] as string[]).concat(helper.JS_EXT, helper.TS_EXT)
      const ext = path.extname(modulePath).toLowerCase()
      const extMatched = !!extensions.find(e => e === ext)
      // when platformExt is empty string('') it means find modulePath itself
      const platformExts = [`.${platform}`, '.rn', '']
      // include ext
      if (extMatched) {
        for (const plat of platformExts) {
          const platformModulePath = modulePath.replace(ext, `${plat}${ext}`)
    	  // 判断是否有对应平台的后缀文件,如果有就直接返回对应平台的后缀文件,替换掉默认的那个
          if (fs.existsSync(platformModulePath)) {
            return platformModulePath
          }
        }
      }
      // handle some omit situations
      for (const plat of platformExts) {
        for (const omitExt of omitExtensions) {
          const platformModulePath = `${modulePath}${plat}${omitExt}`
          if (fs.existsSync(platformModulePath)) {
            return platformModulePath
          }
        }
      }
      // it is lookup in directory and the file path not exists, then return origin module path
      if (isDirectory) {
        return path.dirname(modulePath) // modulePath.replace(/\/index$/, '')
      }
      // handle the directory index file
      const moduleIndexPath = path.join(modulePath, 'index')
      return lookup(moduleIndexPath, platform, true)
    }
    
    • 对于 单个样式文件里使用 不同平台的兼容性样式 无法处理, Taro 这里引入了 条件编译的方式进行 处理, 处理方式如下:
    /*  #ifdef  %PLATFORM%  */
    样式代码
    /*  #endif  */
    /*  #ifndef  %PLATFORM%  */
    样式代码
    /*  #endif  */
    

    Taro 使用postcss的插件,通过css.walkComments遍历注释 方式 判断是否截取注释内部的有效代码

    //packages/postcss-pxtransform/index.js
    /*  #ifdef  %PLATFORM%  */
        // 平台特有样式
        /*  #endif  */
        css.walkComments(comment => {
          const wordList = comment.text.split(' ')
          // 指定平台保留
          if (wordList.indexOf('#ifdef') > -1) {
            // 非指定平台
            if (wordList.indexOf(options.platform) === -1) {
              let next = comment.next()
    		      // 循环取到下一行内容 直接remove,直到遇到#endif 为止
              while (next) {
                if (next.type === 'comment' &amp;&amp; next.text.trim() === '#endif') {
                  break
                }
                const temp = next.next()
                next.remove()
                next = temp
              }
            }
          }
        })
    /*  #ifndef  %PLATFORM%  */
        // 平台特有样式
        /*  #endif  */
        css.walkComments(comment => {
          const wordList = comment.text.split(' ')
          // 指定平台剔除
          if (wordList.indexOf('#ifndef') > -1) {
            // 指定平台
            if (wordList.indexOf(options.platform) > -1) {
              let next = comment.next()
    		   // 循环取到下一行内容 直接remove,直到遇到#endif 为止
              while (next) {
                if (next.type === 'comment' &amp;&amp; next.text.trim() === '#endif') {
                  break
                }
                const temp = next.next()
                next.remove()
                next = temp
              }
            }
          }
        })
    

    总结

    • 优点

    简单易懂,对于前端开发者比较亲切,都是比较传统的概念,易于理解

    • 缺点
    1. 在ts/js代码中会有大量 if/else 充斥其中,后期变得维护困难
    2. 遇到 条件使用 外部npm 包的时候需要是用到 require, 无法使用import, 对于tree-shaking会失效(tree-shaking的消除原理是依赖于ES6的模块特性)

    Chameleon

    Chameleon 提出多态协议的概念,通过多态接口/多态组件/多态模版/样式多态 四种类型来区分多平台侧的适配。

    1. 多台接口
    <script cml-type="wx">
    class Method implements UtilsInterface {
     getMsg(msg) {
       return 'wx:' + msg;
     }
    }
    export default new Method();
    </script>
    
    1. 多态组件
    <template c-else-if="{{ENV === 'wx'}}">
      // 假设wx-list 是微信小程序原生的组件
      <wx-list data="{{list}}"></wx-list>
    </template>
    
    1. 多态模版
    <template class="demo-com">
      <cml type="wx">
        <view>wx端以这段代码进行渲染</view>
        <demo-com ></demo-com>
      </cml>
      <cml type="base">
        <view
          >如果找不到对应端的代码,则以type='base'这段代码进行渲染,比如这段代码会在web端进行渲染</view
        >
        <demo-com ></demo-com>
      </cml>
    </template>
    
    1. 样式多态
    <style>
    @media cml-type (支持的平台) {
    
    }
    .common {
      /**/
    }
    <style>
    

    源码核心库:

    扒一扒 前端跨平台框架Taro/Chamelon/uniApp 多端差异性实现 - 掘金

    这里我们只需要关注 Chameleon 将 代码转化成 DSL协议进行多平台条件判断,利用 babel 转化为 ast 语法树,在对 ast 语法树解析的过程中,对于每个节点通过 tapable 控制该节点的处理方式,比如标签解析、样式语法解析、循环语句、条件语句、原生组件使用、动态组件解析等,达到适配不同端的需求,各端适配互相独立,互不影响,支持快速适配多端。CML的模板解析的整体架构如下图所示

    扒一扒 前端跨平台框架Taro/Chamelon/uniApp 多端差异性实现 - 掘金

    // packages/chameleon-template-parse/src/common/process-template.js
    /* 提供给 chameleon-loader 用于删除多态模板多其他端的不用的代码
    @params:source 模板内容
    @params:type 当前要编译的平台,用于截取多态模板
    @params:options needTranJSX 需要转化为jsx可以解析的模板;needDelTemplate 需要删除template节点
    */
    exports.preParseMultiTemplate = function(source, type, options = {}) {
      try {
        if (options.needTranJSX) { // 当调用这个方法之前没有事先转义jsx,那么就需要转义一下
          let callbacks = ['preDisappearAnnotation', 'preParseGtLt', 'preParseBindAttr', 'preParseVueEvent', 'preParseMustache', 'postParseLtGt'];
          source = exports.preParseTemplateToSatisfactoryJSX(source, callbacks);
        }
        let isEmptyTemplate = false;
        const ast = babylon.parse(source, {
          plugins: ['jsx']
        })
        traverse(ast, {
          enter(path) {
            let node = path.node;
            if (t.isJSXElement(node) &amp;&amp; (node.openingElement.name &amp;&amp; typeof node.openingElement.name.name === 'string' &amp;&amp; node.openingElement.name.name === 'template')) {
              path.stop();// 不要在进行子节点的遍历,因为这个只需要处理template
              let {hasCMLTag, hasOtherTag, jsxElements} = exports.checkTemplateChildren(path);
              if (hasCMLTag &amp;&amp; hasOtherTag) {
                throw new Error('多态模板里只允许在template标签下的一级标签是cml');
              }
              if (hasCMLTag &amp;&amp; !hasOtherTag) {// 符合多态模板的结构格式
                let currentPlatformCML = exports.getCurrentPlatformCML(jsxElements, type);
                if (currentPlatformCML) {
                  currentPlatformCML.openingElement.name.name = 'view';
                  // 这里要处理自闭和标签,没有closingElement,所以做个判断;
                  currentPlatformCML.closingElement &amp;&amp; (currentPlatformCML.closingElement.name.name = 'view');
                  node.children = [currentPlatformCML];
                  if (options.needDelTemplate) { // 将template节点替换成找到的cml type 节点;
                    path.replaceWith(currentPlatformCML)
                  }
                } else {
                  // 如果没有写对应平台的 cml type='xxx' 或者 cml type='base',那么报错
                  throw new Error('没有对应平台的模板或者基础模板')
                }
              } else { // 不是多态模板
                // 注意要考虑空模板的情况
                if (options.needDelTemplate &amp;&amp; jsxElements.length === 1) { // 将template节点替换成找到的cml type 节点;
                  path.replaceWith((jsxElements[0]));
                } else {
                  isEmptyTemplate = true;
                }
              }
            }
          }
        });
        // 这里注意,每次经过babel之后,中文都需要转义过来;
        if (isEmptyTemplate) {
          return '';
        }
        source = exports.postParseUnicode(generate(ast).code);
        if (/;$/.test(source)) { // 这里有个坑,jsx解析语法的时候,默认解析的是js语法,所以会在最后多了一个 ; 字符串;但是在 html中 ; 是无法解析的;
          source = source.slice(0, -1);
        }
        return source;
      } catch (e) {
        console.log('preParseMultiTemplate', e)
      }
    }
    

    这里进行ast 代码分析,将其他非对应平台的代码进行删除处理,确保打包到对应平台的代码纯净性。 而 对 样式多态的处理,直接使用正则匹配判断是否需要进行样式删除,然后 循环迭代方式截取对用平台样式,删除不需要的样式!!#ff0000 (代码里有详细的算法说明)!!

    // packages/chameleon-css-loader/parser/media.js
    module.exports = function parse(source = '', targetType) {
      let reg = /@media\s*cml-type\s*\(([\w\s,]*)\)\s*/g;
      if (!reg.test(source)) {
        return source;
      }
      reg.lastIndex = 0;
      /**
       * 假如:输入是 @media cml-type(wx) {
                    body {
    
                    }
                 }
       * 
       */
     
      while (true) { // eslint-disable-line
        //  找到样式里所有 @media cml-type(wx) 这种类型的样式,知道全部被替换掉为止
        let result = reg.exec(source);
        if (!result) {break;}
        let cmlTypes = result[1] || '';
        cmlTypes = cmlTypes.split(',').map(item => item.trim());
        let isSave = ~cmlTypes.indexOf(targetType);
    
        let startIndex = result.index; // @media的开始
    
        let currentIndex = source.indexOf('{', startIndex); // 从第一个@media开始
        let signStartIndex = currentIndex; // 第一个{的位置
        if (currentIndex == -1) {
          throw new Error("@media cml-type format err");
        }
    
        let signStack = []; // 存放 { 的个数
        signStack.push(0);
        
         /*
           校验 @media cml-type(wx) {} 是否书写正确, 并找出@media {} 的位置,匹配到的最后一个},
           
            第一轮循环: index1 和 index2 都不是 -1, index 取小的那个就是body 后面那个,然后currenIndex 和 sign 取到的是 { 
                   signStack.push(1) 继续循环 signStack = [0, 1]
            第二轮循环, currentIndex 从 body { 下一个字符开始, index1 为 -1, index2 匹配到 body { 后面的 }不是 -1,index 取大的那个就是 body 闭合的}
                      currenIndex 和 sign 取到的是 body 后第一个 }的位置,然后 signStack 就pop一个处理 与 } 匹配
                      此时 signStack 还有一个 0, 表示 还有一个 @media cml-type(wx) 后面的 { 没有被循环掉,继续循环 signStack = [0]
            第三轮循环。 currentIndex 从 body { } 下一个字符开始, index1 为 -1, index2 匹配到 body {} 后面的 } 不是 -1,
                      index 取大的那个就是 body{} 后面那个 } 可以与@media后的 { 闭合
                      currenIndex 和 sign 取到的是 body {} 后第一个 }的位置,然后 signStack 就pop一个 ,处理 与 } 匹配
                      此时 signStack 为 [], 刚好匹配成功,停止循环
            这里其实可以优化 signStack 可以使用 数字替代,初始化 signStack = 1, 匹配一个{ push 表示signStack + 1,pop 表示 signStack - 1
            直到signStack = 0 为止便找到最后一个 } 没必要使用数组存储数据
         */
        
        
        while (signStack.length > 0) {
          let index1 = source.indexOf('{', currentIndex + 1); // { 下一个位置
          let index2 = source.indexOf('}', currentIndex + 1); // } 下一个位置
          let index;
          // 都有的话 index为最前面的
          if (index1 !== -1 &amp;&amp; index2 !== -1) {
            index = Math.min(index1, index2);
          } else {
            index = Math.max(index1, index2);
          }
          if (index === -1) {
            throw new Error("@media cml-type format err");
          }
          let sign = source[index];
          currentIndex = index; // 经过循环会取到最后一个 @media cml-type(wx) {} 的 } 的位置
          if (sign === '{') {
            signStack.push(signStack.length);
          } else if (sign === '}') {
            signStack.pop();
          }
        }
    
        // 操作source
        if (isSave) { // 保存的@media
          var sourceArray = Array.from(source);
          /**
           *  Array.splice( index, remove_count, item_list )
           *  startIndex @media的开始, currentIndex - startIndex + 1 表示 @media {...} 里全部的数量
           *  source.slice(signStartIndex + 1, currentIndex) 取到 {} 内的内容进行填充
           */
          sourceArray.splice(startIndex, currentIndex - startIndex + 1, source.slice(signStartIndex + 1, currentIndex));
          source = sourceArray.join('');
        } else { // 删除的
          /**
           *  source.slice(0, startIndex) 取到@media {...} 之前的内容
           *  source.slice(currentIndex + 1) 取到@media {...} 之后的内容
           */
          source = source.slice(0, startIndex) + source.slice(currentIndex + 1);
        }
        reg.lastIndex = 0;
      }
    
      return source;
    
    }
    

    总结

    • 优点
    1. 代码隔离比较清晰,不会造成代码污染
    2. 编译后实现强隔离,不会有无用代码引入
    • 缺点
    1. 自研多态协议,前端新概念,上手有门槛
    2. 基于底层的 DSL 解析,以及AST 语法分析处理,底层架构依赖性 比较强

    uniapp

    uniapp 通过 API/组件/样式的 条件编译实现对于不同平台的适配工作

    1. API 条件编译
    // #ifdef **  %PLATFORM%** 
    平台特有的API实现
    // #endif
    
    1. 组件条件编译
    <!--  #ifdef **  %PLATFORM%**  -->
    平台特有的组件
    <!--  #endif -->
    
    1. 样式条件编译
    /*  #ifdef **  %PLATFORM% **  */
    平台特有样式
    /*  #endif  */
    

    uniapp 内部利用 XRegExp 正则库 正则匹配 文件字符串 循环取出对应平台的内容,删除其他平台的内容。 XRegExp 提供增强的和可扩展的 JavaScript 正则表达式。地址是:github.com/slevithan/x… 使用到的条件判断正则如下:

    //packages/uni-cli-shared/lib/preprocess/lib/regexrules.js
    js : {
        if : {
          start : "[ \t]*(?://|/\\*)[ \t]*#(ifndef|ifdef|if)[ \t]+([^\n*]*)(?:\\*(?:\\*|/))?(?:[ \t]*\n+)?",
          end   : "[ \t]*(?://|/\\*)[ \t]*#endif[ \t]*(?:\\*(?:\\*|/))?(?:[ \t]*\n)?"
        },
    	...
    	}
    }
    

    具体实现可参考 uniApp条件编译原理探索 uni-app 中封装了很多包,在master 仓库中 条件编译 的包是 webpack-preprocess-loader,而next 版本使用 packages/uni-cli-shared/lib/preprocess/lib/preprocess.js 直接在 编译阶段处理,master 中使用webpack 插件的方式进行条件编译处理,而next 版本中提前处理后,可切换到vite 等下一代打包工具中,体验到开发环境 秒级的开发体验

    总结

    • 优点
    1. 操作简单,有过C语言开发经验的比较亲切,隔离作用强,不会造成代码污染
    2. 编译阶段处理后,能 对 import 方式也做 条件编译
    • 缺点
    1. 有一定的代码侵入

      感谢大家浪费宝贵的时间,阅读到这里,作为一个全面性的多端构建框架, 自然也要面临多平台的适配工作,尽管框架已经从底层帮助开发者解决了大部分的跨平台的兼容工作,然后 瓜无滚圆, 金无足赤, 遇到一些 平台特性的问题,仍需要开发者去按需适配。 以上是 差异性实现的全部内容,有不当之处,请批评指正。。。


    下载网 » 扒一扒 前端跨平台框架Taro/Chamelon/uniApp 多端差异性实现 - 掘金

    常见问题FAQ

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

    发表评论

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

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

    联系作者

    请选择支付方式

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