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

    正文概述 掘金(政采云前端团队)   2021-06-02   399

    手把手带你入门Webpack Plugin

    关于 Webpack

    在讲 Plugin 之前,我们先来了解下Webpack。本质上,Webpack是一个用于现代 JavaScript 应用程序的静态模块打包工具。它能够解析我们的代码,生成对应的依赖关系,然后将不同的模块达成一个或多个 bundle。

    手把手带你入门Webpack Plugin

    Webpack的基本概念包括了如下内容:

    1. Entry:Webpack 的入口文件,指的是应该从哪个模块作为入口,来构建内部依赖图。
    2. Output:告诉 Webpack 在哪输出它所创建的 bundle 文件,以及输出的 bundle 文件该如何命名、输出到哪个路径下等规则。
    3. Loader:模块代码转化器,使得 Webpack 有能力去处理除了 JS、JSON 以外的其他类型的文件。
    4. Plugin:Plugin 提供执行更广的任务的功能,包括:打包优化,资源管理,注入环境变量等。
    5. Mode:根据不同运行环境执行不同优化参数时的必要参数。
    6. Browser Compatibility:支持所有 ES5 标准的浏览器(IE8 以上)

    了解完 Webpack 的基本概念之后,我们再来看下,为什么我们会需要 Plugin。

    Plugin 的作用

    我先举一个我们政采云内部的案例:

    在 React 项目中,一般我们的 Router 文件是写在一个项目中的,如果项目中包含了许多页面,不免会出现所有业务模块 Router 耦合的情况,所以我们开发了一个 Plugin,将 Router 文件拆散在各个子页面的文件夹中,在构建打包时,只需要读取所有的文件夹下的index.js文件,再合并到一起形成一个统一的 Router 文件即可,轻松解决业务耦合问题。这就是 Plugin 的应用(具体实现会在最后一小节说明)。

    来看一下我们合成前项目代码结构:

    ├── package.json
    ├── README.md
    ├── zoo.config.js
    ├── .eslintignore
    ├── .eslintrc
    ├── .gitignore
    ├── .stylelintrc
    ├── build (Webpack 配置目录)
    │   └── webpack.dev.conf.js
    ├── src
    │   ├── index.hbs
    │   ├── main.js (入口文件)
    │   ├── common (通用模块,包权限,统一报错拦截等)
    │       └── ...
    │   ├── components (项目公共组件)
    │       └── ...
    │   ├── layouts (项目顶通)
    │       └── ...
    │   ├── utils (公共类)
    │       └── ...
    │   ├── routes (页面路由)
    │   │   ├── Hello (对应 Hello 页面的代码)
    │   │   │   ├── config (页面配置信息)
    │   │   │       └── ...
    │   │   │   ├── models (dva数据中心)
    │   │   │       └── ...
    │   │   │   ├── services (请求相关接口定义)
    │   │   │       └── ...
    │   │   │   ├── views (请求相关接口定义)
    │   │   │       └── ...
    │   │   │   └── index.js (router定义的路由信息)
    ├── .eslintignore
    ├── .eslintrc
    ├── .gitignore
    └── .stylelintrc
    

    再看一下经过 Plugin 合成 Router 之后的结构:

    ├── package.json
    ├── README.md
    ├── zoo.config.js
    ├── .eslintignore
    ├── .eslintrc
    ├── .gitignore
    ├── .stylelintrc
    ├── build (Webpack 配置目录)
    │   └── webpack.dev.conf.js
    ├── src
    │   ├── index.hbs
    │   ├── main.js (入口文件)
    │   ├── router-config.js (合成后的router文件)
    │   ├── common (通用模块,包权限,统一报错拦截等)
    │       └── ...
    │   ├── components (项目公共组件)
    │       └── ...
    │   ├── layouts (项目顶通)
    │       └── ...
    │   ├── utils (公共类)
    │       └── ...
    │   ├── routes (页面路由)
    │   │   ├── Hello (对应 Hello 页面的代码)
    │   │   │   ├── config (页面配置信息)
    │   │   │       └── ...
    │   │   │   ├── models (dva数据中心)
    │   │   │       └── ...
    │   │   │   ├── services (请求相关接口定义)
    │   │   │       └── ...
    │   │   │   ├── views (请求相关接口定义)
    │   │   │       └── ...
    ├── .eslintignore
    ├── .eslintrc
    ├── .gitignore
    └── .stylelintrc
    

    总结来说 Plugin 的作用总结如下:

    1. 提供了 Loader 无法解决的一些其他事情
    2. 提供强大的扩展方法,能执行更广的任务
    3. Webpack 自身也是构建在插件系统之上的

    了解完 Plugin 的大致作用之后,我们来聊一聊如何创建一个 Plugin。

    创建一个 Plugin

    Hook

    在聊创建 Plugin 之前,我们先来聊一下什么是 Hook。

    Webpack 在编译的过程中会触发一系列流程,而在这样一连串的流程中,Webpack 把一些关键的流程节点暴露出来供开发者使用,这就是 Hook,可以类比 React 的生命周期钩子。

    Plugin 就是在这些 Hook 上暴露出方法供开发者做一些额外操作,在写 Plugin 的时候,也需要先了解我们应该在哪个 Hook 上做操作。

    如何创建 Plugin

    我们先来看一下 Webpack 官方给的案例:

    const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
    ​
    class ConsoleLogOnBuildWebpackPlugin {
        apply(compiler) {
            // 代表开始读取records之前执行
            compiler.hooks.run.tap(pluginName, compilation => {
                console.log("webpack 构建过程开始!");
            });
        }
    }
    

    从上面的代码我们可以总结如下内容:

    • Plugin 其实就是一个类。
    • 类需要一个 apply 方法,执行具体的插件方法。
    • 插件方法做了一件事情就是在 run 这个 Hook 上注册了一个同步的打印日志的方法。
    • apply 方法的入参注入了一个 compiler 实例,compiler 实例是 Webpack 的支柱引擎,代表了 CLI 和 Node API 传递的所有配置项。
    • Hook 回调方法注入了 compilation 实例,compilation 能够访问当前构建时的模块和相应的依赖。
    Compiler 对象包含了 Webpack 环境所有的的配置信息,包含 options,loaders,plugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯一的,可以简单地把它理解为 Webpack 实例;
    ​
    Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。
                                                  —— 摘自「深入浅出 Webpack」
    
    • compiler 实例和 compilation 实例上分别定义了许多 Hooks,可以通过实例.hooks.具体Hook访问,Hook 上还暴露了 3 个方法供使用,分别是 tap、tapAsync 和 tapPromise。这三个方法用于定义如何执行 Hook,比如 tap 表示注册同步 Hook,tapAsync 代表 callback 方式注册异步 hook,而 tapPromise 代表 Promise 方式注册异步 Hook,可以看下 Webpack 中关于这三种类型实现的源码,为方便阅读,我加了些注释。
    // tap方法的type是sync,tapAsync方法的type是async,tapPromise方法的type是promise
    // 源码取自Hook工厂方法:lib/HookCodeFactory.js
    create(options) {
      this.init(options);
      let fn;
      // Webpack 通过new Function 生成函数
      switch (this.options.type) {
        case "sync":
          fn = new Function(
            this.args(), // 生成函数入参
            '"use strict";\n' +
            this.header() + // 公共方法,生成一些需要定义的变量
            this.contentWithInterceptors({ // 生成实际执行的代码的方法
              onError: err => `throw ${err};\n`, // 错误回调
              onResult: result => `return ${result};\n`, // 得到值的时候的回调
              resultReturns: true,
              onDone: () => "",
              rethrowIfPossible: true
            })
          );
          break;
        case "async":
          fn = new Function(
            this.args({
              after: "_callback"
            }),
            '"use strict";\n' +
            this.header() + // 公共方法,生成一些需要定义的变量
            this.contentWithInterceptors({ 
              onError: err => `_callback(${err});\n`, // 错误时执行回调方法
              onResult: result => `_callback(null, ${result});\n`, // 得到结果时执行回调方法
              onDone: () => "_callback();\n" // 无结果,执行完成时
            })
          );
          break;
        case "promise":
          let errorHelperUsed = false;
          const content = this.contentWithInterceptors({
            onError: err => {
              errorHelperUsed = true;
              return `_error(${err});\n`;
            },
            onResult: result => `_resolve(${result});\n`,
            onDone: () => "_resolve();\n"
          });
          let code = "";
          code += '"use strict";\n';
          code += this.header(); // 公共方法,生成一些需要定义的变量
          code += "return new Promise((function(_resolve, _reject) {\n"; // 返回的是 Promise
          if (errorHelperUsed) {
            code += "var _sync = true;\n";
            code += "function _error(_err) {\n";
            code += "if(_sync)\n";
            code +=
              "_resolve(Promise.resolve().then((function() { throw _err; })));\n";
            code += "else\n";
            code += "_reject(_err);\n";
            code += "};\n";
          }
          code += content; // 判断具体执行_resolve方法还是执行_error方法
          if (errorHelperUsed) {
            code += "_sync = false;\n";
          }
          code += "}));\n";
          fn = new Function(this.args(), code);
          break;
      }
      this.deinit(); // 清空 options 和 _args
      return fn;
    }
    

    Webpack 共提供了以下十种 Hooks,代码中所有具体的 Hook 都是以下这 10 种中的一种。

    // 源码取自:lib/index.js
    "use strict";
    ​
    exports.__esModule = true;
    // 同步执行的钩子,不能处理异步任务
    exports.SyncHook = require("./SyncHook");
    // 同步执行的钩子,返回非空时,阻止向下执行
    exports.SyncBailHook = require("./SyncBailHook");
    // 同步执行的钩子,支持将返回值透传到下一个钩子中
    exports.SyncWaterfallHook = require("./SyncWaterfallHook");
    // 同步执行的钩子,支持将返回值透传到下一个钩子中,返回非空时,重复执行
    exports.SyncLoopHook = require("./SyncLoopHook");
    // 异步并行的钩子
    exports.AsyncParallelHook = require("./AsyncParallelHook");
    // 异步并行的钩子,返回非空时,阻止向下执行,直接执行回调
    exports.AsyncParallelBailHook = require("./AsyncParallelBailHook");
    // 异步串行的钩子
    exports.AsyncSeriesHook = require("./AsyncSeriesHook");
    // 异步串行的钩子,返回非空时,阻止向下执行,直接执行回调
    exports.AsyncSeriesBailHook = require("./AsyncSeriesBailHook");
    // 支持异步串行 && 并行的钩子,返回非空时,重复执行
    exports.AsyncSeriesLoopHook = require("./AsyncSeriesLoopHook");
    // 异步串行的钩子,下一步依赖上一步返回的值
    exports.AsyncSeriesWaterfallHook = require("./AsyncSeriesWaterfallHook");
    // 以下 2 个是 hook 工具类,分别用于 hooks 映射以及 hooks 重定向
    exports.HookMap = require("./HookMap");
    exports.MultiHook = require("./MultiHook");
    

    举几个简单的例子:

    • 上面官方案例中的 run 这个 Hook,会在开始读取records之前执行,它的类型是 AsyncSeriesHook,查看源码可以发现,run Hook 既可以执行同步的 tap 方法,也可以执行异步的 tapAsync 和 tapPromise 方法,所以以下写法也是可以的:
    const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
    ​
    class ConsoleLogOnBuildWebpackPlugin {
        apply(compiler) {
            compiler.hooks.run.tapAsync(pluginName, (compilation, callback) => {
                setTimeout(() => {
                  console.log("webpack 构建过程开始!");
                  callback(); // callback 方法为了让构建继续执行下去,必须要调用
                }, 1000);
            });
        }
    }
    
    • 再举一个例子,比如 failed 这个 Hook,会在编译失败之后执行,它的类型是 SyncHook,查看源码可以发现,调用 tapAsync 和 tapPromise 方法时,会直接抛错。

    对于一些同步的方法,推荐直直接使用 tap 进行注册方法,对于异步的方案,tabAsync 通过执行 callback 方法实现回调,如果执行的方法返回的是一个 Promise,推荐使用 tapPromise 进行方法的注册

    Hook 的类型可以通过官方 API 查询,地址传送门:www.webpackjs.com/api/compile…

    // 源码取自:lib/SyncHook.js
    const TAP_ASYNC = () => {
      throw new Error("tapAsync is not supported on a SyncHook");
    };
    ​
    const TAP_PROMISE = () => {
      throw new Error("tapPromise is not supported on a SyncHook");
    };
    ​
    function SyncHook(args = [], name = undefined) {
      const hook = new Hook(args, name);
      hook.constructor = SyncHook;
      hook.tapAsync = TAP_ASYNC;
      hook.tapPromise = TAP_PROMISE;
      hook.compile = COMPILE;
      return hook;
    }
    

    讲解完具体的执行方法之后,我们再聊一下 Webpack 流程以及 Tapable 是什么。

    Webpack && Tapable

    Webpack 运行机制

    要理解 Plugin,我们先大致了解 Webpack 打包的流程

    1. 我们打包的时候,会先合并 Webpack config 文件和命令行参数,合并为 options
    2. 将 options 传入 Compiler 构造方法,生成 compiler 实例,并实例化了 Compiler 上的 Hooks
    3. compiler 对象执行 run 方法,并自动触发 beforeRun、run、beforeCompile、compile 等关键 Hooks
    4. 调用 Compilation 构造方法创建 compilation 对象,compilation 负责管理所有模块和对应的依赖,创建完成后触发 make Hook
    5. 执行 compilation.addEntry() 方法,addEntry 用于分析所有入口文件,逐级递归解析,调用NormalModuleFactory 方法,为每个依赖生成一个 Module 实例,并在执行过程中触发 beforeResolve、resolver、afterResolve、module 等关键 Hooks
    6. 将第 6 步中生成的 Module 实例作为入参,执行 Compilation.addModule() 和 Compilation.buildModule() 方法递归创建模块对象和依赖模块对象
    7. 调用 seal 方法生成代码,整理输出主文件和chunk,并最终输出

    手把手带你入门Webpack Plugin

    Tapable

    Tapable 是 Webpack 核心工具库,它提供了所有 Hook 的抽象类定义,Webpack 许多对象都是继承自 Tapable 类。比如上面说的 tap、tapAsync 和 tapPromise 都是通过 Tapable 进行暴露的。源码如下(截取了部分代码):

    // 第二节 “创建一个 Plugin” 中说的 10种Hooks都是继承了这两个类// 源码取自:tapable.d.tsdeclare class Hook<T, R, AdditionalOptions = UnsetAdditionalOptions> {  tap(options: string | Tap & IfSet<AdditionalOptions>, fn: (...args: AsArray<T>) => R): void;}​declare class AsyncHook<T, R, AdditionalOptions = UnsetAdditionalOptions> extends Hook<T, R, AdditionalOptions> {  tapAsync(    options: string | Tap & IfSet<AdditionalOptions>,    fn: (...args: Append<AsArray<T>, InnerCallback<Error, R>>) => void  ): void;  tapPromise(    options: string | Tap & IfSet<AdditionalOptions>,    fn: (...args: AsArray<T>) => Promise<R>  ): void;}
    

    常见 Hooks API

    可以参考 Webpack可以参考:www.webpackjs.com/api/compile…

    本文列举一些常用 Hooks 和其对应的类型

    Compiler Hooks

    Hooktype调用
    runAsyncSeriesHook开始读取 records 之前compileSyncHook一个新的编译(compilation)创建之后emitAsyncSeriesHook生成资源到 output 目录之前doneSyncHook编译(compilation)完成

    Compilation Hooks

    Hooktype调用
    buildModuleSyncHook在模块构建开始之前触发。finishModulesSyncHook所有模块都完成构建。optimizeSyncHook优化阶段开始时触发。

    Plugin 在项目中的应用

    讲完这么多理论知识,接下来我们来看一下Plugin 在项目中的实战:如何在业务中将router-config.js 拆分带各个子模块中。

    背景:

    在 React 项目中,一般我们的 Router 文件是写在一个项目中的,如果项目中包含了许多页面,不免会出现所有业务模块 Router 耦合的情况,所以我们开发了一个 Plugin,将 Router 文件拆散在各个子页面的文件夹中,在构建打包时,只需要读取所有的文件夹下的 Router 文件,再合并到一起形成一个统一的 Router 文件即可,轻松解决业务耦合问题。这就是 Plugin 的应用。

    实现:

    const fs = require('fs');const path = require('path');const _ = require('lodash');​function resolve(dir) {  return path.join(__dirname, '..', dir);}​function MegerRouterPlugin(options) {  // options是配置文件,你可以在这里进行一些与options相关的工作}​MegerRouterPlugin.prototype.apply = function (compiler) {  // 注册 before-compile 钩子,触发文件合并  compiler.plugin('before-compile', (compilation, callback) => {    // 最终生成的文件数据    const data = {};    const routesPath = resolve('src/routes');    const targetFile = resolve('src/router-config.js');    // 获取路径下所有的文件和文件夹    const dirs = fs.readdirSync(routesPath);    try {      dirs.forEach((dir) => {        const routePath = resolve(`src/routes/${dir}`);        // 判断是否是文件夹        if (!fs.statSync(routePath).isDirectory()) {          return true;        }        delete require.cache[`${routePath}/index.js`];        const routeInfo = require(routePath);        // 多个 view 的情况下,遍历生成router信息        if (!_.isArray(routeInfo)) {          generate(routeInfo, dir, data);        // 单个 view 的情况下,直接生成        } else {          routeInfo.map((config) => {            generate(config, dir, data);          });        }      });    } catch (e) {      console.log(e);    }​    // 如果 router-config.js 存在,判断文件数据是否相同,不同删除文件后再生成    if (fs.existsSync(targetFile)) {      delete require.cache[targetFile];      const targetData = require(targetFile);      if (!_.isEqual(targetData, data)) {        writeFile(targetFile, data);      }    // 如果 router-config.js 不存在,直接生成文件    } else {      writeFile(targetFile, data);    }​    // 最后调用callback,继续执行 webpack 打包    callback();  });};// ​合并当前文件夹下的router数据,并输出到 data 对象中function generate(config, dir, data) {  // 合并 router  mergeConfig(config, dir, data);  // 合并子 router  getChildRoutes(config.childRoutes, dir, data, config.url);}// 合并 router 数据到 targetData 中function mergeConfig(config, dir, targetData) {  const { view, models, extraModels, url, childRoutes, ...rest } = config;  // 获取 models,并去除 src 字段  const dirModels = getModels(`src/routes/${dir}/models`, models);  const data = {    ...rest,  };  // view 拼接到 path 字段  data.path = `${dir}/views${view ? `/${view}` : ''}`;  // 如果有 extraModels,就拼接到 models 对象上  if (dirModels.length || (extraModels && extraModels.length)) {    data.models = mergerExtraModels(config, dirModels);  }  Object.assign(targetData, {    [url]: data,  });}// 拼接 dva modelsfunction getModels(modelsDir, models) {  if (!fs.existsSync(modelsDir)) {    return [];  }  let files = fs.readdirSync(modelsDir);  // 必须要以 js 或者 jsx 结尾  files = files.filter((item) => {    return /\.jsx?$/.test(item);  });  // 如果没有定义 models ,默认取 index.js  if (!models || !models.length) {    if (files.indexOf('index.js') > -1) {      // 去除 src      return [`${modelsDir.replace('src/', '')}/index.js`];    }    return [];  }  return models.map((item) => {    if (files.indexOf(`${item}.js`) > -1) {      // 去除 src      return `${modelsDir.replace('src/', '')}/${item}.js`;    }  });}// 合并 extra modelsfunction mergerExtraModels(config, models) {  return models.concat(config.extraModels ? config.extraModels : []);}// 合并子 routerfunction getChildRoutes(childRoutes, dir, targetData, oUrl) {  if (!childRoutes) {    return;  }  childRoutes.map((option) => {    option.url = oUrl + option.url;    if (option.childRoutes) {      // 递归合并子 router      getChildRoutes(option.childRoutes, dir, targetData, option.url);    }    mergeConfig(option, dir, targetData);  });}​// 写文件function writeFile(targetFile, data) {  fs.writeFileSync(targetFile, `module.exports = ${JSON.stringify(data, null, 2)}`, 'utf-8');}​module.exports = MegerRouterPlugin;
    

    结果:

    合并前的文件:

    module.exports = [  {    url: '/category/protocol',    view: 'protocol',  },  {    url: '/category/sync',    models: ['sync'],    view: 'sync',  },  {    url: '/category/list',    models: ['category', 'config', 'attributes', 'group', 'otherSet', 'collaboration'],    view: 'categoryRefactor',  },  {    url: '/category/conversion',    models: ['conversion'],    view: 'conversion',  },];
    

    合并后的文件:

    module.exports = {  "/category/protocol": {    "path": "Category/views/protocol"  },  "/category/sync": {    "path": "Category/views/sync",    "models": [      "routes/Category/models/sync.js"    ]  },  "/category/list": {    "path": "Category/views/categoryRefactor",    "models": [      "routes/Category/models/category.js",      "routes/Category/models/config.js",      "routes/Category/models/attributes.js",      "routes/Category/models/group.js",      "routes/Category/models/otherSet.js",      "routes/Category/models/collaboration.js"    ]  },  "/category/conversion": {    "path": "Category/views/conversion",    "models": [      "routes/Category/models/conversion.js"    ]  },}
    

    最终项目就会生成 router-config.js文件

    手把手带你入门Webpack Plugin

    结尾

    希望大家看完本章之后,对 Webpack Plugin 有一个初步的认识,能够上手写一个自己的 Plugin 来应用到自己的项目中。

    文章中如有不对的地方,欢迎指正。

    推荐阅读

    通过自定义Vue指令实现前端曝光埋点

    H5 页面列表缓存方案

    开源作品

    • 政采云前端小报

    开源地址 www.zoo.team/openweekly/ (小报官网首页有微信交流群)

    招贤纳士

    政采云前端团队(ZooTeam),一个年轻富有激情和创造力的前端团队,隶属于政采云产品研发部,Base 在风景如画的杭州。团队现有 40 余个前端小伙伴,平均年龄 27 岁,近 3 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、性能体验、云端应用、数据分析及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。

    如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变既定的节奏,将会是“5 年工作时间 3 年工作经验”;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 ZooTeam@cai-inc.com

    手把手带你入门Webpack Plugin


    起源地下载网 » 手把手带你入门Webpack Plugin

    常见问题FAQ

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

    发表评论

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

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

    联系作者

    请选择支付方式

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