Detailed explanation of Medusa WeChat mini program engineering practice plan

coldplay.xixi
Release: 2020-09-19 10:08:47
forward
3160 people have browsed it

Detailed explanation of Medusa WeChat mini program engineering practice plan

Related learning recommendations: WeChat Mini Program Tutorial

##Foreword

I have published "

Practical Chapter - Exploration of WeChat Mini Program Engineering - Webpack" was the first stage of my exploration of WeChat Mini Program engineering. At first, I just wanted to verify whether the WeChat applet and webpack could be combined (largely driven by curiosity about technology), and I didn’t think too much about engineering continuous delivery. However, under the constant impact of internal needs, I began to have the idea of ​​​​continuously simplifying the difficulty of developing WeChat mini programs through engineering methods. The final product was this rapid development solution for WeChat mini programs named after Medusa. Next, I will share the practical process of achieving this solution in more detail. The tools mentioned below have also been published on npm for everyone to download and use. This article will cover all the content of the previously published article and will be richer in content, so it will be longer. Please read it patiently.

webpack-build-miniprogram

webpack-build-miniprogram is the foundation and core of the Medusa solution. This toolkit provides the ability to build WeChat mini programs with webpack, and we can use webpack The ecology continues to enrich Medusa's functions. Before talking about the basic construction configuration, let's first take a look at the basic directory structure of Medusa. Only with corresponding directory constraints can the project be more standardized.

|-- dist                        编译结果目录
|-- src                         源代码目录
|   |-- app.js                  项目入口文件
|   |-- app.json                小程序配置文件
|   |-- sitemap.json            sitemap配置文件
|   |-- assets                  静态资源存放目录
|   |   |-- .gitkeep
|   |-- components              公共组件存放目录
|   |   |-- .gitkeep
|   |-- dicts                   公共字典存放目录
|   |   |-- .gitkeep
|   |-- libs                    第三方工具库存放目录(外部引入)
|   |   |-- .gitkeep
|   |-- pages                   页面文件存放目录
|   |   |-- index
|   |       |-- index.js
|   |       |-- index.json
|   |       |-- index.less
|   |       |-- index.wxml
|   |-- scripts                 公共脚本存放目录(wxs)
|   |   |-- .gitkeep
|   |-- services                API服务存放目录
|   |   |-- .gitkeep
|   |-- styles
|   |   |-- index.less          项目总通用样式
|   |   |-- theme.less          项目主题样式
|   |-- templates               公共模板存放目录
|   |   |-- .gitkeep
|   |-- utils                   公共封装函数存放目录(自我封装)
|       |-- .gitkeep
|-- .env                        环境变量配置文件
|-- config.yaml                 编译配置文件
|-- webpack.config.js           webpack 配置扩展文件
|-- project.config.json         开发者工具配置文件
└── package.json复制代码
Copy after login

Basics

webpack This tool has now become an essential skill for front-end engineers. Its complex working principle makes us always have a sense of awe for it, so we are making WeChat mini programs. In the process of building a strategy, we first simply understand it as a "transportation tool". It performs some processing on the files in the source code directory and then outputs them to the target directory. Now let’s clarify which files we want to move. The main ones involved in the WeChat applet are:

    Logical file
  • .js
  • Configuration file
  • .json
  • Template file
  • .wxml
  • Style file
  • .wxss .less .scss
  • Script file
  • .wxs
  • Static resource file
  • assets/
Basic transport function

Next we will write the public part configuration of webpack and use the copy-webpack-plugin plug-in to complete the moving of most files.

/** config/webpack.common.js */const CopyPlugin = require("copy-webpack-plugin");const config = {  context: SOURCE,  devtool: 'none',  entry: {        app: './app.js'
  },  output: {    filename: '[name].js',    path: DESTINATION
  },  plugins: [    new CopyPlugin([
      {        from: 'assets/',        to: 'assets/',        toType: 'dir'
      },
      {        from: '**/*.wxml',        toType: 'dir'
      },
      {        from: '**/*.wxss',        toType: 'dir'
      },
      {        from: '**/*.json',        toType: 'dir'
      },
      {        from: '**/*.wxs',        toType: 'dir'
      }
    ])
  ]
};复制代码
Copy after login

With the above simple configuration, we have realized the transfer work except for the logical files and precompiled language files. In the configuration, there are two

SOURCE and DESTINATION Constants, they respectively represent the absolute paths of the source code directory and the target code directory. We extract them in separate dictionary files:

/** libs/dicts.js */const path = require("path");

exports.ROOT = process.cwd();
exports.SOURCE = path.resolve(this.ROOT, 'src');
exports.DESTINATION = path.resolve(this.ROOT, 'dist');
exports.NODE_ENV = process.argv.splice(2, 1)[0];复制代码
Copy after login

The files moved above do not require special content processing, so It is completely left to the plug-in to implement. For the remaining two types of files, we need to use the

entry, plugin(plugin) and loader of webpack. Only through cooperation can the moving work be completed.

Core Entry Function

First of all, we have to solve the problem of how to generate the entrance. Only after solving the problem of entrance generation can we use the loader to complete the conversion of the file content. For the problem of entry generation, I developed another plug-in, entry-extract-webpack-plugin, to solve it. I do not intend to explain the implementation process of this plug-in in detail. I will only elaborate on its core implementation ideas (if you are interested in learning more, you can download it and look at the source code directly).

WeChat applet needs to establish an entrance network according to the rules. The main package and sub-package will be configured in the app.json file, and the components required for the page will also be configured in the [page].json file. Grasping this feature, we can list the core of plug-in functions as follows:

  • path and# provided by node.js ##fs The module function, based on the path configured in the app.json file, recursively searches for the component paths that each page depends on, and finally integrates them into the same array. Use the SingleEntryPlugin and MultiEntryPlugin plug-ins provided by webpack to inject the path array collected in the first step into the build in the entryOption life cycle hook to form the
  • entry.
  • If a new page is added during the process of building the listener, the new entry will be added to the previous
  • entry
  • through the watchRun life cycle.
  • The above three points are the core ideas for realizing the function of
generating entry

. In addition to the core implementation ideas, I also want to briefly explain how we write a webpack plug-in. :

class EntryExtractPlugin {  constructor(options) {}
  
  apply(compiler) {
    compiler.hooks.entryOption.tap('EntryExtractPlugin', () => {
        ...
    });
    compiler.hooks.watchRun.tap('EntryExtractPlugin', () => {
        ...
    });
  }
}复制代码
Copy after login

webpack 的插件大致是以的形式存在,当你使用插件时,它会自动执行 apply 方法, 然后使用 compiler.hooks 对象上的各种生命周期属性便可以将我们需要的处理逻辑植入到 webpack 的构建流程当中。

逻辑与样式

上面解决了生成入口(entry)的问题,接下来我们在原有的基础上完善一下策略。由于预编译语言的类型较多,我为了策略的可扩展性将样式部分的策略抽离为单独的部件,然后在通过 webpack-merge 这一工具将它们合并起来,完整的实现如下:

/** config/webpack.parts.js */exports.loadCSS = ({ reg = /\.css$/, include, exclude, use = [] }) => ({    module: {    rules: [
      {
        include,
        exclude,        test: reg,        use: [
          {            loader: require('mini-css-extract-plugin').loader
          },
          {            loader: 'css-loader'
          }
        ].concat(use)
      }
    ]
  }
});复制代码
Copy after login
/** config/webpack.common.js */const { merge } = require('webpack-merge');const MiniCssExtractPlugin = require('mini-css-extract-plugin');const parts = require('./webpack.parts.js');const config = {
  ...
  module: {    rules: [
      {        test: /\.js$/,        loader: 'babel-loader',        exclude: /node_modules/
      }
    ]
  },  plugins: [
    ...
    new MiniCssExtractPlugin({ filename: '[name].wxss' })
  ]
};module.export = merge([
    config,
  parts.loadCSS({    reg: /\.less$/,    use: ['less-loader']
  })
]);复制代码
Copy after login

以上就是基础的 webpack 策略,接下来我们书写一个工具包的可执行文件便可以在项目当中通过 medusa-server {mode} 使用 webpack 提供的功能了。我们需要在工具包的 package.json 文件中配置 bin 字段,它标志了我们通过命令会自动执行哪个文件。

{
  ...
  main: "index.js",
  bin: {    "medusa-server": "index.js"
  }
}复制代码
Copy after login
/** index.js */const webpack = require('webpack');const { merge } = require('webpack-merge');const chalk = require('chalk');const commonConfig = require('./config/webpack.common');const { NODE_ENV } = require('./libs/dicts');const config = (function(mode) {  if (mode === 'production') {    return merge([commonConfig, { mode }]);
  }  return merge([commonConfig, {    mode: 'development',    watch: true,    watchOptions: { ignored: /node_modules/ }
  }])
})(NODE_ENV);

webpack(config, (err, stats) => {  if (err) {    console.log(chalk.red(err.stack || err));    if (err.details) {        console.log(chalk.red(err.details));
    }    return undefined;
  }  const info = stats.toJson();  if (stats.hasErrors()) {    console.log(chalk.red(info.errors));
  }  if (stats.hasWarnings()) {    console.log(chalk.yellow(info.warnings));
  }
});复制代码
Copy after login

进阶篇

基础篇当中完成的 webpack 配置已经可以满足两点功能,一是对常规文件的输出,二是具备开发与生产两种不同的模式。对于基础篇我还有两点要说明一下:
为什么没有应用ES6(更改版本)转为ES5的相关插件?
因为在实践当中发现IOS的10.x版本存在async/await语法无法正常使用的情况,所以索性就让构建更加纯粹一些 ,然后启用微信开发者工具的 ES6 转 ES5增强编译 这两项功能,由官方工具去处理新特性。
为什么 devtool 要设置为 none,难道不需要 sourceMap 吗?
微信官方已经原生提供了SourceMap功能,这在你上传版本时开发者工具中就已经有体现了。
接下来就进入进阶篇的梳理,在满足正常的输出后其实与原生开发好像并没有太大差异,这完全体现不出 webpack 的作用,所以我们要利用 webpack 的能力及其相关的工具生态来扩展下 Medusa 的功能。接下来我们将赋予 Medusa 以下几点功能:

  • 路径别名 @
  • 根据环境自动注入相应的环境域名
  • ESLint、StyleLint代码规范检查
  • 路由功能
  • 公共代码抽取
  • 环境变量
  • webpack 可扩展

路径别名 @

原生微信小程序只支持相对路径的引入方式,但是我们难免会遇到必须移动某些文件的情况,假设这个文件在多处被引用,那就头疼了。所以我们通过 webpack 的能力以及搭配 jsconfig.json 配置文件可以让我们有更好的开发体验。

/** config/webpack.common.js */const config = {
  ...
  resolve: {    alias: {        '@': SOURCE
    }
  }
};复制代码
Copy after login
{  "compilerOptions": {    "baseUrl": ".",    "paths": {      "@/*": ["./src/*"],
    },    "target": "ES6",    "module": "commonjs",    "allowSyntheticDefaultImports": true,
  },  "include": ["src/**/*"],  "exclude": ["node_modules"],
}复制代码
Copy after login

通过 alias 别名配置,@ 符号就可以指代 src 源代码目录,然后在实际业务项目的根目录下创建一个 jsconfig.json 文件,通过以上的配置在使用 vscode 编辑器时就可以获得良好的智能路径提示功能。

自动注入相应的环境域名

在介绍这个功能之前,我需要先讲解一下 config.yaml 这个配置文件的作用。这是我一开始设想用来扩展各种功能的配置文件,它应用在实际业务项目的根目录中。现阶段它的功能还相当少,不过将来应该会逐步迭代增加的。

# 当前项目应用平台platform: wx# 样式单位 px 转换 rpx 的比例设定css_unit_ratio: 1# 域名配置development_host:  api: https://www.miniprogram.dev.comproduction_host:  api: https://www.miniprogram.pro.com复制代码
Copy after login

这其中的域名配置是可以自由增减的,书写形式形如例子当中所示。当我们在其中配置好对应环境的域名后,我们在业务代码当中便可以用 mc.$hosts.api 变量轻松的访问域名。在构建过程中工具会自动帮你将对应环境的域名注入代码当中,你再也不用关心如何去管理和切换不同环境域名的问题了。下面展示一下我是如何实现这一功能的:

/** libs/dicts */exports.CONFIG = path.resolve(this.ROOT, 'config.yaml');
exports.DEFAULT_CONFIG = {  platform: 'wx',  css_unit_ratio: 1,
};复制代码
Copy after login
/** libs/index.js */const fs = require('fs');const yaml = require('js-yaml');const { CONFIG, DEFAULT_CONFIG } = require('./dicts');

exports.yamlConfig = (function() {  try {    /** 将 yaml 格式的内容解析为 object 对象 */
    const config = yaml.load(fs.readFileSync(CONFIG, { encoding: 'utf-8' }));    return config;
  } catch(e) {    return DEFAULT_CONFIG;
  }
})();复制代码
Copy after login
/** webpack.common.js */const webpack = require('webpack');const { yamlConfig } = require('../libs');const { NODE_ENV } = require('../libs/dicts');const config = {
  ...
  plugins: [
    ...
    new webpack.DefinePlugin({        mc: JSON.stringify({        $hosts: yamlConfig[`${NODE_ENV}_host`] || {}
      })
    })
  ]
};复制代码
Copy after login

webpack.DefinePlugin 插件可以判别代码中 mc 这个标识符然后进行相应的替换功能,这样就能保证代码的正常运作。

ESLint 与 StyleLint

编码规范已经是老生常谈的话题了,今年我所处的前端团队扩容到二十几人,在多人共同协作和不同成员维护的情况下,我们迫切的需要统一的编码规范来减少维护的成本。(在规范落地上,我还有两句题外话要说,当我们需要落地一项规范的时候,不要停留在讨论规则上,也不要妄想用人的自律去约束编码。如果你的团队也需要实施这些东西,希望你能够成为先驱者将规则先定下初版,然后找一个合适的工具,通过工具去约束人的行为。)接下来我们就来看下我们所需要的检查工具是如何配置的:

/** webpack.common.js */const StylelintPlugin = require('stylelint-webpack-plugin');const config = {  module: {    rules: [
      {        enforce: 'pre',        test: /\.js$/,        exclude: /node_modules/,        loader: 'eslint-loader',        options: {            fix: true,          cache: false,          formatter: require('eslint-friendly-formatter')
        }
      }
    ]
  },  plugins: [    new StylelintPlugin({        fix: true,      files: '**/*.(sa|sc|le|wx|c)ss'
    })
  ]
};复制代码
Copy after login

以上配置的两个工具可以对 js 与样式文件按照一定的规则集进行检查,对于部分不符合规范的代码可以自行修复,无法自动修复的部分将会给出相应的提示。应用什么规则集你可以通过业务项目根目录中的 .eslintrc.stylelintrc 文件去决定(下面的章节我会对规范这一块再进行相应的展开)。

公共代码抽取

在应用 webpack 之后,我们为微信小程序开发赋予了 npm 加载依赖的能力。在模块化打包的机制下,工具包会被加载进引用它的入口当中,这会导致微信小程序包大小及其容易就超出限定值了,所以我们的解决方案是将多次出现的代码抽离为单独的公共部分,具体的实施代码如下:

/** config/webpack.common.js */const config = {
  ...
  output: {
    ...,    globalObject: 'global'
  },  plugins: [
    ...
    new webpack.BannerPlugin({        raw: true,      include: 'app.js',      banner: 'const vendors = require("./vendors");\nconst commons = require("./commons");\nconst manifest = require("./manifest");'
    })
  ],  optimization: {    cacheGroups: {        vendors: {        chunks: 'initial',        name: 'vendors',        test: /[\\/]node_modules[\\/]/,        minChunks: 3,        priority: 20
      },      commons: {        chunks: 'initial',        name: 'commons',        test: /[\\/](utils|libs|services|apis|models|actions|layouts)[\\/]/,        minChunks: 3,        priority: 10
      }
    },    runtimeChunk: {        name: 'manifest'
    }
  }
};复制代码
Copy after login

微信小程序的全局变量可以通过 global 访问,所以我们需要将 output.globalObject 属性设置为 global。webpack 内置的 BannerPlugin 插件可以将我们需要的语句插入在指定的文件的头部,利用它我们就做到了将抽离出来的公共文件重新引入到依赖网格中。

环境变量

我们在使用 Vue 、React 的脚手架创建的项目时会见到 .env 这个文件,它的主要作用是扩展开发者所需的环境变量。在微信小程序扩展环境变量变量这样的需求可能少之又少,但是我们可以换一种思路,我们可以利用它来扩展开发者所需的全局变量。接下来我们将利用 dotenv-webpack 这一工具来实现这个功能,它可以读取业务项目根目录下的 .env 文件内容,使得我们在编码中也可以使用当中的变量值。

/** config/webpack.common.js */const Dotenv = require('dotenv-webpack');const { ENV_CONFIG } = require('../libs/dicts');const config = {
  ...
  plugins: [
    ...
    new Dotenv({ path: ENV_CONFIG })
  ]
};复制代码
Copy after login

webpack 扩展

通过 webpack 我已经实现了通用的许多功能,但是我所处的公司开发的微信小程序颇多,所以难免有一些项目需要个性化的定制策略。既然有这样的需求,我们就应该提供这样的能力给到业务开发者,一来我们可以从多个项目当中吸收更多需要集成的功能点,二来可以暂时减轻自身的负担。webpack-merge 这一工具其实在前面样式部分合并我已经使用过了,我们只需要提供业务项目 webpack 配置的路径即可,再修改下之前的执行文件。

/** index.js */const { WEBPACK_CONFIG } = require('./libs/dicts');const config = (function(mode) {  if (mode === 'production') {    return merge([commonConfig, { mode }, WEBPACK_CONFIG]);
  }  return merge([commonConfig, {    mode: 'development',    watch: true,    watchOptions: { ignored: /node_modules/ }
  }, WEBPACK_CONFIG])
})(NODE_ENV);

...复制代码
Copy after login

路由功能

微信官方提供了关于路由跳转的 API ,但我认为官方的 API 在日常开发中有几点不便:

  1. 需要输入完整的路径字符串,路径太长难以记忆不说,假如页面路径有所修改需要投入较高的维护成本。
  2. 跳转方式多样,四种不同类型的跳转 API 较为常用的是 navigateTo,因为 API 有多个并且参数也较多,所以使用时难免都省不了再去查阅一遍文档。
  3. 官方提供的任何一种 API 最终目标页面中接收到的 query 都是字符串类型,这一定程度上限制了我们的编码设计。

为了解决上述的三个问题,我将 API 进行了二次封装,从中抹除四种跳转类型的差异,通过统一的接口就可以达到四种跳转方法的效果。并且通过 webpack 的全局变量注入功能优化了路径字符串的获取,方便使用并且容易维护。
mc.$routes
我将页面的文件夹名称与路径相关联,两者形成映射关系的话,我们只需要书写文件夹名称便可。原来我们需要使用 pages/home/index 访问 home 页面,通过我的改造之后,我们可以通过 mc.$routes.home 访问 home 页面。
medusa-wx-router
medusa-wx-router 是我对路由功能进行二次封装后的工具包,具体的实现过程在这里我就不详述了,你可以自行下载使用或是依照你的需求进行再次改造,下面我只展示一下在业务代码中结合 mc.$routes 如何使用:

mc.routerTo({  url: mc.$routes.home,  type: 'push',  query: {    id: 0,    bool: true
  },  success: () => console.log('successfully')
});/** push 方式快捷形式 */mc.routerTo(mc.$routes.home, { id: 0, bool: true });复制代码
Copy after login

为了省去 import 路由工具包这一步骤,我使用了 webpack.ProvidePlugin 这一插件自动帮我们在有使用的地方补充 import 功能。

/** config/webpack.common.js */const config = {
  ...
  plugins: [
    ...
    new webpack.ProvidePlugin({      'mc.routerTo': ['medusa-wx-router', 'routerTo'],      'mc.decoding': ['medusa-wx-router', 'decoding'],      'mc.back': ['medusa-wx-router', 'back'],      'mc.goHome': ['medusa-wx-router', 'goHome'],
    })
  ]
};复制代码
Copy after login

规范

规范是工程的重要一环,一个团队必须遵照同一套规则进行编码,规范的存在使得代码的质量得以提升,有统一的规范认知使得成员互相交接项目更加轻松高效。在前面的实践当中,我已经将规范的检查工具集成在了构建流程当中,本节我将补充一下应用于微信小程序的规则集配置和相关编辑器插件,当然我还希望你阅读一下我对于命名规范的一些总结希望对你有所启发《你可能需要的统一命名规范》。
规则集其实就是一个包含规则的配置文件,接下来我会给出具体的配置内容。当然在考虑到规则集的团队定制性和升级的问题,我将 ESLint 和 StyleLint 的规则都制作成了 npm 包,这就解决了所有业务项目统一规则的问题。对应的 npm 包分别是 eslint-config-medusa 和 stylelint-config-medusa ,这是我所处的团队所需要的,所以对于你们在实践时可以结合你们团队的现有情况进行改造。

# EditorConfig is awesome: https://EditorConfig.org

# top-most EditorConfig file
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false复制代码
Copy after login

在业务项目的根目录中创建 .editorconfig 文件配合 vscode 的 EditorConfig for VS Code 插件便可以对常规的文件进行基础的编码约束。

module.exports = {  parserOptions: {    ecmaVersion: "2018",    sourceType: "module"
  },  parser: "babel-eslint",  env: {    node: true,    commonjs: true,    es6: true
  },  globals: {    mc: false,    wx: true,    swan: true,    App: true,    Page: true,    Component: true,    Behavior: true,    getApp: true,    getCurrentPages: true
  },  extends: ["airbnb-base"],  rules: {    // disallow use of console
    "no-console": "warn",    // disallow use of debugger
    "no-debugger": "error",    // disallow dangling underscores in identifiers
    'no-underscore-dangle': 'off',    // specify the maximum length of a line in your program
    "max-len": ["error", 120],    // require return statements to either always or never specify values
    "consistent-return": ["error", { "treatUndefinedAsUnspecified": true }],    // disallow usage of expressions in statement position
    "no-unused-expressions": "off"
  },  settings: {    "import/resolver": {      alias: {        map: [
          ["@", "./src"],
          ["utils", "./src/utils"],
          ["services", "./src/services"]
        ],        extensions: [".js", ".json", ".less"]
      }
    }
  }
};复制代码
Copy after login
module.exports = {  rules: {    // Disallow unknown type selectors
    'selector-type-no-unknown': null,    // Disallow unknown units
    'unit-no-unknown': null
  },  extends: [    'stylelint-config-standard',    'stylelint-config-recess-order'
  ],  plugins: ['stylelint-order']
};复制代码
Copy after login

上面展示的就是我所完成的两个规则集依赖包的入口文件,当我们 install 依赖包之后,我们就可以在业务项目的 .eslintrc 和 .stylelintrc 文件中通过 extends 字段将它们引入。搭配 vscode 编辑器的 ESLint 和 stylelint-plus 插件,它们可以在编码过程中就提醒你相应的错误规则。

脚手架

前面说的构建与规范全都是服务于具体的业务项目的,在拥有了基础的能力之后,我们就该思考如何使得业务开发人员能够快速并且符合要求的进行业务系统开发。让他们不再需要考虑目录应该怎么约定,工具如何集成,编码规范究竟如何应用诸如这些问题。为了达成快速开发这一要求,我着手制作具备初始化项目这一简易功能的脚手架。最终我将这一工具分为两个项目,其一是具备初始化项目结构、下载相关依赖包功能的 @chirono/medusa-cli,另外是约定好项目结构与必要配置文件的 miniprogram-base 。
业务开发者只需要通过 npm 全局安装 @chirono/medusa-cli 工具,便可以通过 medusa create <project-name> 命令初始化一个工程项目,该工具还会提示必要的项目信息让开发者输入,用于完善业务项目的 package.json 文件。

结语

以上是我对 Medusa 这一工程的总结,现在这一工程已经进入较为稳定的阶段也已经顺利投入到多个实际项目当中。对于上述的总结可能有某些部分写得相对简略,如果你却有兴趣我建议你直接下载源码去研究,因为编码的实际操作确实无法用三言两语解释清楚。我写下这一篇文章主要想表达的是工程链路的完整性能够为实际的项目开发产生相当大的效益,而且实践工程当中不仅是单一工具的开发,要将工具串起来。接下来我可能还会对UI打包、TS编译、测试工具这些内容进行知识总结。最后,感谢你的阅读,如果你有疑问或是建议可以留言与我共同探讨。

想了解更多编程学习,敬请关注php培训栏目!

The above is the detailed content of Detailed explanation of Medusa WeChat mini program engineering practice plan. For more information, please follow other related articles on the PHP Chinese website!

Related labels:
source:juejin.im
Statement of this Website
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn
Popular Tutorials
More>
Latest Downloads
More>
Web Effects
Website Source Code
Website Materials
Front End Template