小程序多端适配实践

小程序多端适配实践

前言

为减少多端代码维护成本,提升开发体验,实现一套代码适配支付宝小程序、微信小程序,当前小程序框架使用的是Taro,本质上它已经帮助开发者做了很多多端适配的事情,但是由于不同的平台之间还是存在一些无法消除的差异,如编译时和运行时代码需要开发者进行处理。

适配思路和方案

环境变量

用于判断当前的编译平台类型。 取值如:weapp/alipay/h5可以通过这个process.env.TARO_ENV变量来区分不同环境,从而使用不同的逻辑。在编译阶段,会移除不属于当前编译类型的代码,只保留当前编译类型下的代码。

在微信小程序和支付宝端分别引用第三方富文本文件

javascript 复制代码
// 如mpHtml富文本组件
let mpUrl = '../component/mpHtml/index'
if (process.env.TARO_ENV === 'alipay') {
  mpUrl = '../component/mpAlipay/index'
}
export default {
  navigationBarTitleText: 'mpHtml多端适配例子',
  usingComponents: {
    // 定义需要引入的第三方组件
    // 1. key 值指定第三方组件名字,以小写开头
    // 2. value 值指定第三方组件 js 文件的相对路径
    'mp-html': mpUrl,
  },
}

决定不同端要加载的组件

JavaScript 复制代码
{process.env.TARO_ENV === 'weapp' && <View>这是在微信上运行的组件<View>}
{process.env.TARO_ENV === 'alipay' && <View>这是在支付宝上运行的组件<View>}

组件文件中跨平台支持

为了方便书写样式跨端的组件代码,目前在.sass或.less文件中支持条件编译的特性。

例如,希望某段模板内容只在 支付宝小程序中 生效,可以这么写:

SCSS 复制代码
/*  #ifdef alipay  /
.taroify-tabs__tab { min-width: 24%; }
/  #endif  */

多端组件

假如有一个 Test 组件存在微信小程序、支付宝小程序两个不同版本,那么就可以像如下组织代码:

├── test.js Test 组件默认的形式,编译到微信小程序支付宝小程序之外的端使用的版本

├── test.weapp.js Test 组件的微信小程序版本

├── test.alipay.js Test 组件的支付宝小程序版本

三个文件,对外暴露的是统一的接口,它们接受一致的参数,只是内部有针对各自平台的代码实现。

而使用 Test 组件的时候,引用的方式依然和之前保持一致。import 的是不带端类型的文件名,在编译的时候Taro会自动识别并添加端类型后缀:

javascript 复制代码
import Test from '../../components/test'
<Test argA={1} argA={2} />

多端页面路由

可以根据不同平台,设置不同的路由规则。例如

javascript 复制代码
// app.config.js
let pages = []
if (process.env.TARO_ENV === 'weapp') {
  pages = ['/pages/index/index']
}
if (process.env.TARO_ENV === 'alipay') {
  pages = ['/pages/indexAlipay/indexAlipay']
}
export default {
  pages,
}

处理编译后的小程序原生文件

如果你不想在业务代码里增加额外的代码,可以开发一个自定义的webpack plugin,原理是改变编译后的小程序原生代码(如wxml、wxss、js、json为后缀名的文件),实现对原生小程序代码文件增删改。例如:

javascript 复制代码
// 删除支付宝端暂时不需要的pages模块
const path = require('path')
const fs = require('fs')
class AdapterAlipayWebpackPluginForPackage {
  constructor(options) {
    this.options = options
  }
  deleteFolderRecursive(folderPath) {
    if (fs.existsSync(folderPath)) {
      fs.readdirSync(folderPath).forEach((file) => {
        const curPath = path.join(folderPath, file)
        if (fs.lstatSync(curPath).isDirectory()) {
          this.deleteFolderRecursive(curPath)
        } else {
          fs.unlinkSync(curPath)
        }
      })
      fs.rmdirSync(folderPath)
    }
  }
  apply(compiler) {
    compiler.hooks.done.tap('AdapterAlipayWebpackPluginForPackage', (stats) => {
      if (this.options.env === 'alipay') {
        const outputPath = stats.compilation.outputOptions.path // 输出目录路径
        // 删除文件夹
        this.options.confUnnecessaryModulesForAlipay.forEach((folder) => {
          let folderPath = path.resolve(outputPath, 'pages', folder.split('/')[1])
          this.deleteFolderRecursive(folderPath)
        })
      }
    })
  }
}
module.exports = AdapterAlipayWebpackPluginForPackage

支付宝支付

业务架构图

需要先在支付宝管理后台完成开发设置产品绑定绑定商家账号 ,后续如果需要迭代维护,开发者只需要关心用户授权、获取交易号唤起收银台判断支付是否成功这四步。

前端逻辑

通过用户授权获取

比如用户点击页面上某个支付按钮,用户端有授权弹窗

  • 前端通过 my.getAuthCode 方法获取到用户的授权 authCode;

  • authCode 传到后端通过 alipay.system.oauth.token 接口获取 access_token 参数;

  • 后端用 access_token 参数传入alipay.user.info.share 接口中获取到 user_id(openid);

javascript 复制代码
my.getAuthCode({
      scopes: 'auth_user',
      success: (res) => {
        const authCode = res.authCode
        const params = {
          code: authCode,
          clientType: 'alipay_mini_app',
          appId: 'xxx'
        }
        // 把params这些参数传给后端
      },
      fail: (err) => {
        console.log('my.getAuthCode 调用失败', err)
      },
    })

获取交易号

user_id信息获取到之后,将订单id通过后端提供的接口传给服务端

JavaScript 复制代码
// 例子
my.request({
      url: '商家服务端地址',// 须加httpRequest域白名单
      method: 'POST',
      data: {
        //data里的key、value是开发者自定义的
        payClient: '1', // 支付宝
        paymentType: '2', // 小程序支付
        payChannel: '1', // 移动端
        orderId: 'xxx',//订单id
      },
      dataType: 'json',
      success: function(res) {
        // 拿到 trade_no(支付宝交易号)
      },
      fail: function(res) {
        //;
      }
    });

唤起收银台

拿到 trade_no(支付宝交易号),通过支付宝官方提供的接口my.tradePay唤起收银台

javascript 复制代码
my.tradePay({
  // 传入支付宝交易号trade_no,唤起收银台
  tradeNO: trade_no,
  success: (res) => {
    // 成功回调
  },
  fail: (res) => {
    // 失败回调
  }
});

判断支付是否成功回调

javascript 复制代码
// 因支付结果是异步的,时间取决于银行,最好是通过后端提供的接口查询是否已支付成功
if (res.resultCode === '9000') {
            // 订单处理成功
            resolve({ ...res })
          } else if (res.resultCode === '8000' || res.resultCode === '6004') {
            // 6004 处理结果未知(有可能已经成功)
            // 8000	正在处理中。支付结果未知(有可能已经支付成功)
            resolve(res)
          } else {
            reject(res)
            showToast({
                title: "支付失败" + res.message, // 支付失败
                icon: 'none',
                duration: 2000,
            })
          }

支付宝小程序登录

业务架构图

前端逻辑

小程序获取authCode

可以在登录页面初始化时获取,会弹出授权弹窗

javascript 复制代码
my.getAuthCode({
      scopes: 'auth_user',
      success: (res) => {
        const authCode = res.authCode
        const params = {
          code: authCode,
          clientType: 'alipay_mini_app',// 客户端标识
          appId: "支付宝小程序的appId"
        }
      },
      fail: (err) => {
        console.log('my.getAuthCode 调用失败', err)
      },
    })

带上authCode,调用服务端api进行授权认证,生成ALI_USER_ID

javascriptreact 复制代码
// 后端接口:/user/...
my.request({
        url: 'xxx', // 后端api地址,实现的功能是服务端拿到authCode去开放平台进行token验证
        data: {
          code: authCode,
          clientType: 'alipay_mini_app',// 客户端标识
          appId: "支付宝小程序的appId"
        },
        success: (res) => {
          // 拿到后端返回的ALI_USER_ID
          const ALI_USER_ID = res.data.ALI_USER_ID
        },
        fail: () => {
          // 根据自己的业务场景来进行错误处理
        },
      });

前端声明授权登录组件

javascript 复制代码
<Button
  block
  loading={loading}
  color="primary"
  shape="round"
  size="large"
  open-type="getAuthorize"
  scope="phoneNumber"
  onGetAuthorize={onGetPhoneNumber}
  onError={handleAuthError}
  >
  <Text>一键授权登录</Text>
</Button>

用户信息(手机号)授权

JavaScript 复制代码
my.getPhoneNumber({
        success: (res) => {
          const encryptedData = res.response
          // 带上encryptedData加密字符串调用后端auth接口
        },
        fail: (err) => {
          console.log('授权失败', err)
        },
      })
    }

调用后端auth接口,生成accountId和code

JavaScript 复制代码
// 后端接口:/user/auth
// 前端请求参数
const param = {
          encryptedData,
          openId: ALI_USER_ID,
          appId:"xxxxxxx",
          clientType: 'alipay_mini_app',
}

带上accountId和code,请求服务端获取access_token

javascript 复制代码
// 后端接口:/user/oauth/token
// 前端请求参数
const params = {
  accountId: "xxxx",
  code: "xxxx",
  grant_type: "alipay_mini_app",
  scope: "server"
}
// 接口成功返回后,表示登录已成功
{
	"code": 1,
	"message": "操作成功",
	"traceId": "xxxxx",
	"requestId": null,
	"data": {
		"access_token": "xxxxx",
		"token_type": "bearer",
		"refresh_token": "xxxxx",
		"expires_in": 11119,
		"scope": "server",
		"license": "",
		"user_info": {
			"username": "xxxxx",
			"openId": "xxx",
			"unionId": null,
			"phone": "xxx",
			"userType": "",
			"identityId": null,
			"realName": null,
			"engName": null,
			"nickname": null,
			"gender": null,
			"birthday": null
		},
		"clientId": "alipay_mini_app"
	}
}

webpack plugin适配

原理

自定义plugin本质是 JavaScript 类,该类需要实现 apply 方法,这个方法将会在安装插件时被 webpack 调用。在 apply 方法中,可以使用 webpack 提供的各种钩子函数来注册自定义逻辑,这些钩子函数代表了 webpack 在打包过程中的不同阶段。

在注册的钩子函数中,可以编写自定义的逻辑代码来实现想要的功能,例如对代码进行注入、修改、删除等。

当自定义plugin开发完成之后,使用 webpack 的插件 API 将自定义插件添加到 webpack 的chain链式调用中。这样,在 webpack 运行过程中,就会按照插件的注册顺序执行相应的逻辑。

背景

Taro对多端的适配能力有限,除了Taro官方提供的多端适配方案,我自研了plugin的方案。利用webpack提供的编译时事件流机制,对输出的小程序原生文件进行修改。

例如微信小程序和支付宝小程序的应用配置文件app.json的配置项会略显不同,子包命名在微信小程序下是小写subpackages,在支付宝小程序下是驼峰subPackages,又比如window配置项下面的很多配置微信和支付宝很多字段不一样。下面就这些情况对app.json配置文件做适配。

好处

  1. 减少多套代码维护,开发者只需关心需求迭代上业务代码编写,多端适配交给webpack或者工具自动化来做;

  2. 因为plugin做的事情只是修改了编译后的小程序原生文件,而且这部分是通过Taro编译生成的,避免每次修改本地配置文件生成新git修改记录,对开发者来说,修改多端适配上的配置文件应该是无感知;

  3. 对上层业务代码零侵入,通过自定义webpack plugin写好的脚本,对编译后的文件进行自动化修改。

用法

自定义plugin

javascript 复制代码
// 修改app.json文件
class AdapterAlipayWebpackPlugin {
  constructor(options) {
    this.options = options
  }
  apply(compiler) {
    compiler.hooks.emit.tap('AdapterAlipayWebpackPlugin', (compilation) => {
      if (this.options.env === 'alipay') {
        Object.keys(compilation.assets).forEach((fileName) => {
          if (fileName.endsWith('app.json')) {
            let asset = compilation.assets[fileName]
            let source = asset.source()
            source = source.replaceAll('subpackages', 'subPackages')
            let appJsonObject = JSON.parse(source)
            delete appJsonObject.window['navigationBarBackgroundColor']
            delete appJsonObject.window['backgroundTextStyle']
            delete appJsonObject.window['navigationBarTextStyle']
            appJsonObject.window['titleBarColor'] = '#FFFFFF' // 导航栏背景色
            appJsonObject.window['navigationBarFrontColor'] = 'black' // 导航栏前景色,只支持配置黑色或者白色。
            source = JSON.stringify(appJsonObject, null, 2)
            compilation.assets[fileName] = {
              source() {
                return source
              },
              size() {
                return source.length
              },
            }
          }
        })
      }
    })
  }
}

module.exports = AdapterAlipayWebpackPlugin

注册plugin

javascript 复制代码
const AdapterAlipayWebpackPlugin = require('./AdapterAlipayWebpackPlugin');
const config = {
     // ...
    // 自定义 Webpack 配置
    webpackChain(chain, webpack) {
      chain.plugin('AdapterAlipayWebpackPlugin').use(AdapterAlipayWebpackPlugin, [
        {
          env: process.env.TARO_ENV, // 小程序环境变量
        },
      ])
    }
  }
}
module.export = config

组件适配

原因

这属于运行时的范畴,本人有考虑过用编译时webpack plugin的方案,但效果不好,需要对组件的各种属性进行babel解析、转换、判断等操作,难点在于这些组件的属性并不是固定的,开发者后续维护属性的增删改会变得难以理解和维护,所以使用环境变量process.env.TARO_ENV在业务代码层做适配。

登录组件

javascript 复制代码
<>
      {process.env.TARO_ENV === 'wechat' && 
        <Button
          block
          loading={loading}
          color="primary"
          shape="round"
          openType="getPhoneNumber"
          onGetPhoneNumber={onGetPhoneNumber}
          size="large"
        >
          {/* 一键授权登录 */}
          <Text>一键授权登录</Text>
        </Button>
      }
      {process.env.TARO_ENV === 'alipay' && 
      <Button
          block
          loading={loading}
          color="primary"
          shape="round"
          size="large"
          open-type="getAuthorize"
          scope="phoneNumber"
          onGetAuthorize={onGetPhoneNumber}
          onError={handleAuthError}
        >
          <Text>一键授权登录</Text>
        </Button>}
    </>

JS代码运行时适配

基本思路还是用环境变量process.env.TARO_ENV,把微信和支付的请求参数、请求的jsapi、错误异常统一处理都封装在payment.js文件中,对外暴露接口。

支付例子

javascript 复制代码
import Taro from '@tarojs/taro'
export function adapterRequestPayment(params) {
  return new Promise((resolve, reject) => {
    if (process.env.TARO_ENV === 'alipay') {
      const { aliTradeNo } = params
      my.tradePay({
        // 调用统一收单交易创建接口(alipay.trade.create),获得返回字段支付宝交易号trade_no
        tradeNO: aliTradeNo,
        success: (res) => {
          if (res.resultCode === '9000') {
            // 订单处理成功
            resolve({ ...res, payFlag: 'success' })
          } else if (res.resultCode === '8000' || res.resultCode === '6004') {
            // 6004 处理结果未知(有可能已经成功)
            // 8000	正在处理中。支付结果未知(有可能已经支付成功)
            resolve(res)
          } else {
            reject(res)
          }
        },
        fail: (err) => {
          reject(err)
        },
      })
    } else {
      Taro.requestPayment({
        timeStamp,
        nonceStr,
        package,
        paySign,
        signType,
        success: (res) => {
          resolve(res)
        },
        fail: (err) => {
          reject(err)
        },
      })
    }
  })
}

小程序打包体积优化

为什么

第一次打测试包上传后发现包过大,支付宝对小程序包大小限制,导致上传不了支付宝小程序服务器

打包体积分析

利用小程序开发者工具提供的包依赖分析能力

方案

暂时删除在支付宝端无需上线的功能模块,删除编译后的小程序原生文件,从而减少整包的体积。想法很简单,就是利用webpack删除编译后的pages下面的小程序原生文件,难点在于递归遍历要删除的文件路径,需要考虑边界,同时也需要把app.json配置文件里对应的文件删除,不然会报文件不存在的错。

配置需要删除的文件

见项目根目录下config/envConf.js

javascript 复制代码
// 因上传包大小限制,删除支付宝不需要的模块
const confUnnecessaryModulesForAlipay = [
  'pages/...',
]
module.exports = {
  confUnnecessaryModulesForAlipay
}
开发plugin
javascript 复制代码
// 删除支付宝端暂时不需要的pages模块
const path = require('path')
const fs = require('fs')
class AdapterAlipayWebpackPluginForPackage {
  constructor(options) {
    this.options = options
  }
  deleteFolderRecursive(folderPath) {
    if (fs.existsSync(folderPath)) {
      fs.readdirSync(folderPath).forEach((file) => {
        const curPath = path.join(folderPath, file)
        if (fs.lstatSync(curPath).isDirectory()) {
          this.deleteFolderRecursive(curPath)
        } else {
          fs.unlinkSync(curPath)
        }
      })
      fs.rmdirSync(folderPath)
    }
  }
  apply(compiler) {
    compiler.hooks.done.tap('AdapterAlipayWebpackPluginForPackage', (stats) => {
      if (this.options.env === 'alipay') {
        const outputPath = stats.compilation.outputOptions.path // 输出目录路径
        // 删除文件夹
        this.options.confUnnecessaryModulesForAlipay.forEach((folder) => {
          let folderPath = ''
          // 支持删除pages/大模块/子模块/...这样的文件
          if (folder.indexOf('文件名/') > -1) {
            folderPath = path.resolve(outputPath, 'pages', 'pages下面的某文件名', folder.split('/')[0])
          } else {
            folderPath = path.resolve(outputPath, 'pages', folder.split('/')[1])
          }
          this.deleteFolderRecursive(folderPath)
        })
      }
    })
  }
}
module.exports = AdapterAlipayWebpackPluginForPackage
修改app.json
javascript 复制代码
function deleteUnnecessaryModulesForAlipay(arr, source) {
    source.subPackages = source.subPackages.filter((item) => {
      // 如果需要特别处理
      if (item.pagesPath === 'pages/...') {
        item.pages = item.pages.filter((p) => !arr.includes(p))
        return true
      } else {
        return !arr.includes(item.pagesPath)
      }
    })
    return source
  }
修改打包配置

找到webpack配置文件,如在config/index.js:

javascript 复制代码
const AdapterAlipayWebpackPluginForPackage = require('./AdapterAlipayWebpackPluginForPackage')
const { confUnnecessaryModulesForAlipay } = require('./envConf')
const config = {
  // ...
  mini: {
    // 自定义 Webpack 配置
    webpackChain(chain, webpack) {
      chain
        .plugin('AdapterAlipayWebpackPluginForPackage')
        .use(AdapterAlipayWebpackPluginForPackage, [
          { env: process.env.TARO_ENV, confUnnecessaryModulesForAlipay },
        ])
    }
  },
}
module.esport = config

注入全局变量

痛点

因历史代码原因,小程序的请求头应用主体标识appKey是写死在代码里,这种方式不好的点是当每次切换小程序主体时,开发者都需要在对应文件修改请求头,虽然gulp工具也能处理这种情况,但还是会对业务代码进行浸入式更改,而且每次都会生成几条新的git记录(虽然这个是不必要上传的)

解决方案

方案很简单,就是利用webpack提供的defineConstants机制,注入appKey全局变量。

配置项目全局变量

config/envConf.js:

javascript 复制代码
// 项目全局变量配置
const requstHeaderForAppKey = {
  alipay: 'alipay', // 支付宝
  weapp: 'weapp', // 微信
}
module.exports = {
  requstHeaderForAppKey
}

修改webpack,config/index.js配置文件,增加defineConstants配置项

javascript 复制代码
const config = {
  defineConstants: {
    // 接口请求头
    __requstHeaderForAppKey: JSON.stringify(requstHeaderForAppKey[process.env.TARO_ENV]),
  },
}

修改业务代码

javascript 复制代码
payload: { appKey: __requstHeaderForAppKey }

项目配置文件

在 Taro 小程序的根项目中,project.config.json 是项目的配置文件,用于指定小程序的相关配置信息,用于设置开发者工具的默认行为。

如果是微信小程序默认使用project.config.json,如果是支付宝小程序,用的project.alipay.json,该配置文件开发者只用关注里面的配置项,Taro会根据运行环境自动处理。

报错难点问题处理

useDebounceFn在支付宝下报TypeError: Cannot read property 'now' of undefine

方案

路径src/adapter/lodashFix这个文件是处理ahooks的api:useDebounceFn在支付宝小程序下报Date.now错

javascript 复制代码
import { validCurrentEnv } from './runtimeEnv'
/**
 * 修复支付宝小程序中 lodash/ahooks 的运行环境
 */
export function initLodashRuntime() {
  const env = validCurrentEnv()
  if (env === 'alipay') {
    if (global) {
      global.Date = Date
    }
  }
}
// 在app.js入口文件初始化
initLodashRuntime()

TypeError: Function(...) is not a function

原因

项目中的dva库依赖的@babel/runtime版本是7.23,而@babel/runtime@7.23版本所依赖的regenerator-runtime的版本高于 0.11 版本的话它内部实现使用了 Function() 构造函数,在支付宝小程序环境里不被支持。dva在19年之后就不更新了,导致编译到支付宝报错:TypeError: Function(...) is not a function at Object.node_modulesDvaCoreNode_modulesBabelRuntimeRegeneratorIndexJs

方案

修改yarn.lock中dva-core和dva-loading对应的@babel/runtime版本,固定为7.7.7版本,然后在webpack配置文件里引入该插件,删除依赖node_modules,重新yarn install安装

javascript 复制代码
const fs = require('fs')
class AdapterAlipayYarnLockPlugin {
  constructor(options) {
    this.options = options
  }
  apply(compiler) {
    compiler.hooks.beforeCompile.tapAsync('AdapterAlipayYarnLockPlugin', (params, callback) => {
      if (this.options.env === 'alipay') {
        // 读取yarn.lock文件内容
        const yarnLockContent = fs.readFileSync('./yarn.lock', 'utf-8')
        // 替换目标字符串
        const modifiedContent = yarnLockContent.replaceAll(
          '"@babel/runtime" "^7.0.0"',
          '"@babel/runtime" "7.7.7"',
        )
        // 将修改后的内容写回到yarn.lock文件
        fs.writeFileSync('./yarn.lock', modifiedContent, 'utf-8')
        // 完成插件逻辑
        callback()
      }
    })
  }
}
module.exports = AdapterAlipayYarnLockPlugin

启用小程序基础库2.0构建,1.x有些api在最新版本的支付宝不支持

方案

项目根目录下配置config.alipay.json文件{

json 复制代码
{
  "format": 2, // 启用基础库2.0构建
  "compileOptions": {
    "component2": true
  }
}

const { userName, cardNo } = user,如果user为undefined,在支付宝下报错

方案

const { userName, cardNo } = user || {}

input组件在支付宝下不支持onClick事件

方案

在input的父节点绑定事件

支付宝小程序PickerView在popup组件中无法滚动

原因

支付宝小程序对picker-view二次渲染的处理有问题

方案

popup隐藏的时候把picker-view也销毁掉,开启的时候再重新渲染

支付宝下在当前页面同时设置frontColor和backgroundColor一起设置导航栏标题颜色和图标才会生效

Taro.login不支持支付宝

原因

支付宝小程序的登录接口与微信小程序的登录接口是不同的。

方案

在支付宝小程序中,通过my.getAuthCode()方法获取用户的登录授权码

支付宝小程序获取手机号登录报错:应用内无授权 方案

用户信息未申请: 获取会员手机号,需要到支付宝后台设置小程序的主营类目

mp-html不支持npm依赖包的方式引入

原因

mp-html本身是基于小程序原生开发的文件,需要引入各平台的mp-html

方案

javascript 复制代码
let mpUrl = '../component/mpHtml/index'
if (process.env.TARO_ENV === 'alipay') {
  mpUrl = '../component/mpAlipay/index'
}
export default {
  navigationBarTitleText: '文章',
  usingComponents: {
    // 定义需要引入的第三方组件
    // 1. key 值指定第三方组件名字,以小写开头
    // 2. value 值指定第三方组件 js 文件的相对路径
    'mp-html': mpUrl,
  },
}
相关推荐
轻口味16 分钟前
命名空间与模块化概述
开发语言·前端·javascript
前端小小王1 小时前
React Hooks
前端·javascript·react.js
迷途小码农零零发1 小时前
react中使用ResizeObserver来观察元素的size变化
前端·javascript·react.js
娃哈哈哈哈呀1 小时前
vue中的css深度选择器v-deep 配合!important
前端·css·vue.js
旭东怪2 小时前
EasyPoi 使用$fe:模板语法生成Word动态行
java·前端·word
ekskef_sef3 小时前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6414 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻4 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云4 小时前
npm淘宝镜像
前端·npm·node.js