我为LowCodeEngine低代码引擎写了个插件,让不懂代码的产品人员也能自己开发页面了。

背景

低代码一直以来争议不断,很多人认为低代码平台不好用,坑很多,很多功能实现不了,还不如自己用代码开发。关于这个我说一下我的个人观点,我觉得低代码平台不应该给开发者使用,应该是产品或者业务人员去使用,开发人员去维护低代码平台就好了。

低代码平台还有一个比较难平衡的点,如果想追求简单,难免拓展性和灵活性变差,如果追求灵活性,难免要写一些代码,这样上手难度就变高了。

LowCodeEngine低代码引擎为了追求灵活性,所以对非前端技术人员不太友好,因为很多时候一个很简单的功能都还要去写代码才能实现。

不过LowCodeEngine插件系统很强大,能够快速实现自己想要的功能。下面就和大家分享一下我自己写的一个插件,可以让非技术人员也能快速开发页面。

不使用插件实现一个小功能

下面先给大家演示一下,不使用我写的插件的情况下,实现点击一个按钮,弹出一个弹框整个配置流程。

我这里用的是基于antd物料的官方demo平台,官方还有一些基于其他物料的demo平台,可以到这里查看

先从左侧的组件库中拖一个按钮到画布中

再拖一个弹框组件到画布

再拖一个文本组件到弹框里

在js源码中添加一个变量

给弹框是否可见属性绑定变量

绑定我们刚才在state里添加的变量,这里不知道是bug,还是特性,这里选不到刚才定义的变量,需要自己手写。

变量绑定完成后,可以点击这个给弹框隐藏掉,因为它会干扰我们给按钮绑定事件。

选中按钮,然后给按钮onClick事件绑定方法

这里选择新建一个方法,把visible设置为true。

点击上面的预览按钮,可以看到效果

弹出来之后,发现点击取消和确认不会关闭弹框,需要我们给取消和确认事件绑定方法,这里可以给弹框重新显示出来

onOk事件也绑定onCancel方法

重新预览一下,看一下效果

小结

看了上面步骤,大家是不是觉得很麻烦,并且对非react前端人员一点都不友好,更别说没有代码经验的产品人员了。

站在一个不懂代码人的角度来看这个功能,无非是点一下按钮,弹出一个弹框,如果能把这个步骤可视化出来就好了,给按钮的点击事件直接绑定弹框组件的显示方法,而不是使用代码中的变量去关联。

我做的插件就是把组件之间的逻辑使用可视化的方法配置出来,而不是使用代码去实现联动。

使用插件实现功能

按钮打开弹框

下面使用我写的插件后,来实现一下上面例子。

先把一个按钮和一个弹框到画布中

点击上方的事件管理按钮,这个是自定义插件生成的按钮

点击事件管理按钮后,会弹出一个侧拉框,左侧内容会当前画布中的组件,可以在这里给组件的事件绑定动作。

在左侧选择按钮组件

选择onClick事件

点击开始下面的加号,添加一个动作节点,点击动作节点配置动作类型为组件方法,选择弹框组件的打开弹框方法。

先保存一下,然后再给弹框的onOk事件添加关闭弹框动作,先选中弹框,然后选onOk事件,添加一个动作节点,给动作节点配置关闭弹框。

这样就行了,不用写一行代码。

根据输入网址打开网页

下面再给大家演示一个稍微复杂一点的例子。使用按钮打开用户输入的网址,需要先校验一下用户输入的是否为网址格式,如果格式不对,就不打开,并提示格式错误。

表单联动

再来一个例子,实现简单的表单元素联动。

小结

上面几个例子没有写一行代码实现了一些简单的功能,实现的这些例子虽然简单,但是插件其实已经有了实现复杂功能的基础,并且不需要写一行代码,对不懂代码的人比较友好。

插件内容讲解

前言

根据上面例子可以看出,插件主要更改了LowCodeEngine的事件绑定功能的方式和属性绑定变量的弹框内容。

事件管理

LowCodeEngine原生事件绑定动作的方式需要写一定的代码,对不懂代码的人很不友好,所以我给改了一下,使用可视化的方式配置事件动作,比较符合普通人的思维,上手也很简单。

事件管理页面有四块内容,如下图

组件区:当前画布中存在的组件,可以选择给某个组件事件绑定动作

事件区:可以给当前组件支持的某个事件绑定动作

动作配置区:给前面选择的组件事件绑定动作,因为组件的事件很多情况不只是执行一个动作,所以支持配置多个动作。有时候在执行动作的时候,需要根据条件去执行,所以加了一个条件节点。执行动作后,动作执行完成后可能会有事件,比如调用接口后,接口有成功事件、失败事件,这时候我们可以在接口调用完成后,根据事件执行后续动作,所以还有一个事件节点。

所以目前事件流程配置有以下四种类型节点

  • 开始节点:没有意义,表示事件入口,不能配置。

  • 动作节点:绑定动作,可以连接事件节点和条件节点

  • 条件节点:可以配置多个条件分支,每个条件分支可以绑定一个动作节点,只能连接事件节点。

  • 事件节点:每个动作节点都会有事件节点,只能连接动作节点。

节点配置:目前支持配置的节点只有动作节点和条件节点

  • 动作节点:可以设置具体的执行动作,比如调用组件方法等。

  • 条件节点:可以添加多个条件,每个条件都可以绑定动作。

属性绑定变量

为了上手简单,我把组件属性绑定变量的弹框内容也改造了一下,LowCodeEngine原生绑定变量需要写代码,虽然扩展性很强,但是上手难度很高,所以我给改造成只需要选择组件里暴露出来的变量就行了,还提供了一些常用函数可以用来判断和处理数据,上面demo项目里提供的函数比较少,真实项目可以内置很多常用函数,也可以动态拓展函数。

插件核心技术分享

在实现上面功能的时候,虽然LowCodeEngine文档很详细,但是还是遇到了一些问题,看了源码才解决的。下面和大家分享一下,帮助大家写好自己的插件。

事件管理插件

初始化插件项目

使用下面命令,可以快速创建一个插件

sh 复制代码
npm init @alilc/element your-material-name 

这里类型选择插件

如何拓展一个面板,官方文档很详细,大家可以看一下

插件中使用到api

左侧的组件树数据可以通过下面方法获取,下面代码中ctx是LowCodeEngine引擎注入进来的上下文参数

ts 复制代码
const schema = ctx.project.exportSchema(IPublicEnumTransformStage.Save);
return schema.componentsTree as IPublicTypeRootSchema[];

根据组件名称获取组件中文描述

ts 复制代码
ctx.material.getComponentMetasMap().get(componentName)?.title['zh-CN']

获取当前组件支持那些事件

ts 复制代码
// 通过当前选中的组件id,获取到组件
const node = ctx.project.currentDocument.getNodeById(selectComponentId);
// 根据组件名称,获取组件的props配置
const { props } = ctx.material.getComponentMeta(node.componentName).getMetadata();
// 过滤出事件props
return props.filter(p => p.propType === 'func');

动作流程配置完成后,把数据保存到当前节点的当前事件属性中。

ts 复制代码
// 获取流程编排数据
const data = flowRef.current.save();
// 根据id获取节点
const node = ctx.project.currentDocument.getNodeById(selectComponentId);
// 给节点某个属性设置值
node.props.setPropValue(selectEventName, {
  type: 'flow',
  value: data,
});

下次编辑的时候,获取当前节点事件配置的动作编排数据

ts 复制代码
const node = ctx.project.currentDocument.getNodeById(selectComponentId);
return node.getPropValue(selectEvent);

流程编排

流程编排使用的组件是antv中的G6库,具体实现参考了官方的这个案例

组件方法

有人可能会有疑问,选中了弹框组件组件后,为什么会知道它有打开弹框和关闭弹框方法,这个是在物料组件里配置的,这个下面说到自定义物料的时候再详细说,先和大家说一下,获取物料暴露出来的方法。

ts 复制代码
// 获取节点信息
const node = ctx.project.currentDocument.getNodeById(componentId);
// 根据组件名称获取物料配置信息
const { configure } = ctx.material.getComponentMeta(node.componentName).getMetadata();
// 获取组件暴露出来的可调用方法
return configure?.supports?.methods || [];

变量弹框插件

自定义变量绑定的弹框,这个我在官方文档没有找到,还是看了源码才知道的。

tsx 复制代码
// 注册变量绑定面板,CustomVariableDialog是自定义组件
ctx.skeleton.add({
  area: 'centerArea',
  type: 'Widget',
  content: CustomVariableDialog,
  name: 'variableBindDialog',
  props: {
    ctx,
  },
});

可以在组件内部监听打开变量图标点击事件,打开我们自己的弹框

ts 复制代码
ctx.event.on('common:variableBindDialog.openDialog', ({ field }) => {
  // 获取当前属性绑定的值
  setScriptValue(field.getValue()?.script || '')
  // 显示弹框
  setVisible(true);
  // 保存field对象,因为后面给属性设置值会用到它
  fieldRef.current = field;
});

面板中内置了一些常用函数,如果想拓展也很简单,符合下面数据格式就行。

ts 复制代码
 {
    label: "isUrl",
    template: "func.isUrl(${v1})",
    detail: "判断内容是url",
    type: "function",
    handle: (v1: any) => {
      if (typeof v1 !== "string") return false;
      return /^https?:\/\/(([a-zA-Z0-9_-])+(\.)?)*(:\d+)?(\/((\.)?(\?)?=?&?[a-zA-Z0-9_-](\?)?)*)*$/i.test(
        v1
      );
    },
  },

组件值面板里存放的是组件运行时对外暴露的变量,有哪些变量可以使用也是物料中配置的,这个后面会细说。

ts 复制代码
// 获取组件中暴露出来的值名称和描述
const { values } = ctx.material.getComponentMeta(node.componentName).getMetadata().configure.supports;

右侧的编辑器是我以前使用codemirror6封装的,仓库地址为github.com/dbfu/bp-scr...,大家感兴趣可以去看一下。最大的特点是把插入的变量当成标签,不允许修改。

把编辑器中的脚本保存到属性上,可以使用filed.setValue方法,为啥是这样的数据格式,我后面再说,这里有个很大的坑。

ts 复制代码
fieldRef.current?.setValue({
  type: 'variable',
  value: '[[变量]]',
  script: scriptValue,
});

自定义物料

如何自定义一个物料,官方文档写的很清楚了。我这里给它扩展了两个属性配置,一个对外暴露的方法描述,还有一个是对外暴露的变量值描述。

使用下面命令,可以快速创建一个插件

sh 复制代码
npm init @alilc/element your-material-name 

这里选择物料

然后自定义一个组件,我这里以Modal弹框组件为例。

tsx 复制代码
import { ModalProps, Modal as OriginalModal } from 'antd';
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';

const Modal: any = (props: ModalProps & { setCurPageValue: (fbn: Function) => void, __designMode: string }, ref) => {

  // setCurPageValue和__designMode是低代码引擎注入进来属性,
  // setCurPageValue可以把值设置到全局
  const { setCurPageValue, __designMode, ...rest } = props;

  const [open, setOpen] = useState(false);

  useEffect(() => {
    // 把当前open值暴露出去
    setCurPageValue((prev: any) => ({ ...prev, open }));
  }, [open]);

  // 对外暴露open和close方法
  useImperativeHandle(ref, () => ({
    open: () => {
      setOpen(true);
    },
    close: () => {
      setOpen(false);
    },
  }), []);

  const cancelHandle = (e) => {
    if (props.onCancel) {
      props?.onCancel(e);
    } else {
      setOpen(false);
    }
  };

  const innerProps: any = {};
  if (__designMode === 'design') {
    // 低代码编辑态中强制显示,将控制权交给引擎侧
    innerProps.open = true;
  }
  return <OriginalModal {...rest} open={open} {...innerProps} onCancel={cancelHandle} />;
};


export default forwardRef(Modal);

setCurPageValue是插件里给组件注入的一个方法,可以给组件里的变量暴露到全局。暴露open和close方法,也是为了在组件方法中去调用。

写完组件后,执行npm run lowcode:build命令,会在根目录下生成一个lowcode文件夹,里面存放的是物料描述。找到modal的物料描述,把要对外暴露的方法和值手动配置进去,后面想办法拓展一下官方的插件,自动扫描,不用自己手动配了。

这里配置变量name和方法name要和代码里的一样

自定义render

设计阶段

这里我被卡了一段时间,原因是我自定义了变量绑定的脚本格式,如果按照官方把类型设置为JSExpression,设计阶段会报错,因为他会执行里面的脚本,但是我们的脚本没有按照官方的格式来,所以会报错。

然后我就去翻源码,看看有没有方法绕过,然后就找到了这个代码,组件在渲染前会格式化props。

我开始的想法是重写里面的方法,参考了官方的这篇文档,但是设计阶段不允许自定义PageRenderer,然后继续往下看源码,看到了这段代码。

没想到这段兼容代码帮了我的忙,我只需要给type设置为variable就行了,value为脚本。这样做后我发现了一个新问题,如果给按钮文本绑定变量,设计阶段会把脚本当成文本显示。后来一想把value写死成[[变量]],告诉这里是变量,新加一个script属性存脚本。

预览阶段

前面都是关于配置的,具体怎么把配置转换为可正常使用的功能,还是在这一步。

把配置渲染成功能,官方有一个封装好的ReactRenderer组件,具体怎么使用可以看一下文档

虽然ReactRenderer组件拓展性很强,支持很多东西,比如createElement方法,但是不支持重写__parseProps方法,不重写__parseProps方法,我这里变量绑定的脚本运行就会有问题。

还好预览阶段可以自定义BaseRenderer__parseProps就是BaseRenderer类里的一个方法,我们只需要写一个类继承BaseRenderer,然后只重写__parseProps方法,但是需要自定义PageRenderer,所以官方的就不能用了,不过可以把官方代码拿出来改一改就行了。

tsx 复制代码
import ConfigProvider from '@alifd/next/lib/config-provider';
import {
  adapter,
  addonRendererFactory,
  baseRendererFactory,
  blockRendererFactory,
  componentRendererFactory,
  pageRendererFactory,
  rendererFactory,
  tempRendererFactory,
  types,
} from '@alilc/lowcode-renderer-core';
import { isVariable } from '@alilc/lowcode-utils';
import React, {
  Component,
  ContextType,
  PureComponent,
  ReactInstance,
  createContext,
  createElement,
  forwardRef,
} from 'react';
import ReactDOM from 'react-dom';

window.React = React;
(window as any).ReactDom = ReactDOM;

adapter.setRuntime({
  Component,
  PureComponent,
  createContext,
  createElement,
  forwardRef,
  findDOMNode: ReactDOM.findDOMNode,
});

const BaseRenderer = baseRendererFactory();

class CustomBaseRenderer extends BaseRenderer {
  constructor(props: any, context: any) {
    super(props, context);

    const parseProps = this.__parseProps;
    this.__parseProps = (props: any, self: any, path: string, info: any) => {
      // 这里判断一下如果是变量类型,把type改成script,不然执行base的__parseProps方法还是会问题,这个脚本后面在另外一个地方处理 
      if (isVariable(props) as any) {
        return {
          type: 'script',
          value: props.value,
          script: props.script,
        } as any;
      }
      return parseProps(props, self, path, info);
    };
  }
}

// 把自定义的CustomBaseRenderer,设置进去。
adapter.setRenderers({
  BaseRenderer: CustomBaseRenderer,
} as any);

adapter.setConfigProvider(ConfigProvider);

const PageRenderer = pageRendererFactory();

class CustomPageRenderer extends PageRenderer {
  constructor(props: any, context: any) {
    super(props, context);
  }
}

function factory(): types.IRenderComponent {
  adapter.setRenderers({
    BaseRenderer: CustomBaseRenderer,
    PageRenderer: CustomPageRenderer,
    ComponentRenderer: componentRendererFactory(),
    BlockRenderer: blockRendererFactory(),
    AddonRenderer: addonRendererFactory(),
    TempRenderer: tempRendererFactory(),
    DivRenderer: blockRendererFactory(),
  });

  const Renderer = rendererFactory();

  return class ReactRenderer extends Renderer implements Component {
    readonly props!: types.IRendererProps;

    context: ContextType<any>;

    setState!: (state: types.IRendererState, callback?: () => void) => void;

    forceUpdate!: (callback?: () => void) => void;

    refs!: {
      [key: string]: ReactInstance;
    };

    constructor(props: types.IRendererProps, context: ContextType<any>) {
      super(props, context);
    }

    isValidComponent(obj: any) {
      return obj?.prototype?.isReactComponent || obj?.prototype instanceof Component;
    }
  };
}

export default factory();

这里重写了BaseRenderer的__parseProps方法,把变量类型改了一下,后面在重写createElement时再处理。

PageRenderer写完后,我们下面去使用它

tsx 复制代码
      <ReactRender
        className="lowcode-plugin-sample-preview-content"
        schema={schema}
        components={components}
        customCreateElement={(Component: any, props: any, children: any) => {
          // 给每个组件注入的上下文
          const ctx = {
            pageValue,
            setPageValue,
            getComponentRefs,
          };

          // 当组件配置了是否渲染为变量时,动态执行脚本,如果脚本返回 false,则不渲染
          if (props?.__inner__?.condition && props?.__inner__?.condition?.type === 'variable') {
            if (!execScript(props?.__inner__?.condition?.script, ctx)) return ;
          }

          // 解析 props
          const newProps = parseProps(props, ctx);

          // 渲染组件
          return React.createElement(Component, newProps, newProps.children || children);
        }}
        onCompGetRef={(schema: any, ref: any) => {
          // 存储每个组件的 ref实例
          componentRefs.current = {
            ...componentRefs.current,
            [schema.id]: ref,
          }
        }}
        appHelper={{
          requestHandlersMap: {
            fetch: createFetchHandler()
          }
        }}
      />

这里主要自定义了createElement方法,这样我们可以在组件渲染前,更改props。

把组件实例存放到了componentRefs中,我们只要知道了组件id和方法,就可以通过componentRefs调用它的方法了。

看一下前面打开弹框的配置,知道了组件id,也知道是哪个方法,所以我们就可以调用弹框的打开方法了。

parseProps方法实现,可以看一下代码中的注释

ts 复制代码
export const parseProps = (props: any, ctx: any) => {

  const { setPageValue } = ctx;

  const newProps: any = {
    // 给每个组件注入设置值的方法,让它们把想要暴露出来的值设置到全局
    setCurPageValue: (fn: Function) => {
      setPageValue((prev: any) => ({
        ...prev,
        [props.__id]: fn(prev[props.__id]),
      }))
    }
  };

  Object.keys(props).forEach(key => {
    // 判断是否是事件
    if (key.startsWith('on') && props[key]) {
      const eventConfig = props[key];
      newProps[key] = () => {
        const { type, value } = eventConfig || {};
        // 如果事件绑定的动作为流程,那么去执行流程
        if (type === 'flow') {
          value.children && execEventFlow(value.children, ctx);
        }
      };
    } else if (typeof props[key] === 'object') {
      // 判断是否是脚本
      if (props[key].type === 'script') {
        // 执行脚本
        newProps[key] = execScript(props[key].script, ctx);
      } else {
        newProps[key] = props[key];
      }
    } else {
      newProps[key] = props[key];
    }
  })

  return newProps;
}

execScript方法,使用的是new Function方法动态执行脚本。这里把存放组件暴露出来的值注入到了脚本的上下文中,所以脚本中可以直接获取到某个组件暴露出来的值。

ts 复制代码
export function execScript(script: string, ctx: any) {
  const { pageValue } = ctx;

  if (!script) return;

  const result = script.replace(/\[\[(.+?)\]\]/g, (_: string, $2: string) => {
    const [fieldType, ...rest] = $2.split('.');

    if (fieldType === 'C') {
      const keys = rest.map((t) => t.split(':')[1]);
      return `ctx.lodash.get(ctx.pageValue, "${keys.join('.')}")`;
    }

    return '';
  });

  const func = new Function('ctx', 'func', `return ${result}`);

  const funcs = functions.reduce<any>((prev, cur) => {
    if (cur.handle) {
      prev[cur.label] = cur.handle;
    }
    return prev;
  }, {});

  const funcResult = func(
    {
      pageValue,
      lodash,
    },
    funcs,
  );

  return funcResult;
}

执行事件绑定动作的方法,主要使用了递归。

tsx 复制代码
import { message } from 'antd';
import { execScript } from './exec-script';
import { getPropValue } from './utils';

const actions = [
  {
    name: 'openPage',
    label: '打开页面',
    paramsSetter: [{
      name: 'url',
      label: 'url',
      type: 'input',
      required: true,
    }, {
      name: 'isNew',
      label: '新开窗口',
      type: 'switch',
    }],
    handler: (config: { url: string, isNew: boolean }) => {
      const { url, isNew = false } = config;
      window.open(url, isNew ? '_blank' : '_self');
    }
  },
  {
    name: 'showMessage',
    label: '显示消息',
    paramsSetter: [{
      name: 'type',
      label: '消息类型',
      type: 'select',
      options: [{
        label: 'success',
        value: 'success',
      }, {
        label: 'error',
        value: 'error',
      }],
      defaultValue: 'success',
      required: true,
    }, {
      name: 'text',
      label: '消息内容',
      type: 'input',
      required: true,
    }],
    handler: (config: { type: any, text: any }) => {
      const { type, text } = config;

      if (type === 'success' || type === 'error') {
        message[type as 'success' | 'error'](text);
      }
    }
  },
];

const actionMap = actions.reduce<any>((prev, cur) => {
  prev[cur.name] = cur.handler;
  return prev;
}, {})

async function componentMethod(actionConfig: any, ctx: any) {
  const componentRefs = ctx.getComponentRefs();
  if (!componentRefs[actionConfig.componentId]) {
    return Promise.reject();
  }

  // 拿到组件实例,执行对应的方法
  await componentRefs[actionConfig.componentId][actionConfig.method]();
}

export function execEventFlow(
  nodes: Node[] = [],
  ctx: any,
) {
  if (!nodes.length) return;

  nodes.forEach(async (item: any) => {
    // 判断是否是动作节点,如果是动作节点并且条件结果不为false,则执行动作
    if (item.type === 'action' && item.conditionResult !== false) {

      const { config } = item?.config || {};

      const newConfig: any = {};

      Object.keys(config).forEach((key: any) => {
        newConfig[key] = getPropValue(config[key], ctx);
      });

      try {
        if (item.config.type === 'ComponentMethod') {
          await componentMethod(config, ctx);
        } else {
          // 根据不同动作类型执行不同动作
          await actionMap[item.config.type](
            newConfig,
            ctx,
            item,
          );
        }

        // 如果上面没有抛出异常,执行成功事件的后续脚本
        const children = item.children?.filter((o: any) => o.eventKey === 'success');
        execEventFlow(children, ctx);
      } catch {
        // 如果上面抛出异常,执行失败事件的后续脚本
        const children = item.children?.filter((o: any) => o.eventKey === 'error');
        execEventFlow(children, ctx);
      } finally {
        // 如果上面没有抛出异常,执行finally事件的后续脚本
        const children = item.children?.filter((o: any) => o.eventKey === 'finally');
        execEventFlow(children, ctx);
      }
    } else if (item.type === 'condition') {
      // 如果是条件节点,执行条件脚本,把结果注入到子节点conditionResult属性中
      const conditionResult = (item.config || []).reduce(
        (prev: any, cur: any) => {
          const result = execScript(cur.condition, ctx);
          prev[cur.id] = result;
          return prev;
        },
        {}
      );

      (item.children || []).forEach((c: any) => {
        c.conditionResult = !!conditionResult[c.conditionId];
      });
      // 递归执行子节点事件流
      execEventFlow(item.children, ctx);
    } else if (item.type === 'event') {
      // 如果是事件节点,执行事件子节点事件流
      execEventFlow(item.children, ctx);
    }
  });
}

到此整个插件核心功能介绍的差不多了,插件还没成熟,就先不放出来了,等稍微成熟一点了,会给开源出来的。

我还在陆陆续续加一些功能,比如优化接口调用方式,和后端表模型对接、通过AI快速开发界面等,如果有对这个插件开发感兴趣的,可以在评论区留言讨论。

总结

个人认为LowCodeEngine不一定是一个好的低代码平台,但是它绝对是一个非常强大的低代码引擎,它定义了低代码很多协议和规范,让别人可以在它的基础上快速孵化出一个符合自己产品的低代码平台。

本文正在参加阿里低代码引擎征文活动

相关推荐
浮华似水18 分钟前
Javascirpt时区——脱坑指南
前端
王二端茶倒水21 分钟前
大龄程序员兼职跑外卖第五周之亲身感悟
前端·后端·程序员
_oP_i26 分钟前
Web 与 Unity 之间的交互
前端·unity·交互
钢铁小狗侠28 分钟前
前端(1)——快速入门HTML
前端·html
凹凸曼打不赢小怪兽1 小时前
react 受控组件和非受控组件
前端·javascript·react.js
狂奔solar1 小时前
分享个好玩的,在k8s上部署web版macos
前端·macos·kubernetes
qiyi.sky1 小时前
JavaWeb——Web入门(8/9)- Tomcat:基本使用(下载与安装、目录结构介绍、启动与关闭、可能出现的问题及解决方案、总结)
java·前端·笔记·学习·tomcat
清云随笔1 小时前
axios 实现 无感刷新方案
前端
鑫宝Code1 小时前
【React】状态管理之Redux
前端·react.js·前端框架
忠实米线2 小时前
使用pdf-lib.js实现pdf添加自定义水印功能
前端·javascript·pdf