小程序多端适配实践

小程序多端适配实践

前言

为减少多端代码维护成本,提升开发体验,实现一套代码适配支付宝小程序、微信小程序,当前小程序框架使用的是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,
  },
}
相关推荐
qq22951165023 分钟前
微信小程序的汽车维修预约管理系统
微信小程序·小程序·汽车
我要洋人死17 分钟前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人28 分钟前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人29 分钟前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR34 分钟前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香36 分钟前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q24985969339 分钟前
前端预览word、excel、ppt
前端·word·excel
小华同学ai44 分钟前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
Gavin_9151 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
懒大王爱吃狼2 小时前
Python教程:python枚举类定义和使用
开发语言·前端·javascript·python·python基础·python编程·python书籍