ARouter完全解析(一)源码详解

前言

在前面的文章组件化中用到了ARouter框架,它是专门用来做组件化改造的,官方定义如下:

一个用于帮助Android App进行组件化改造的框架 ------ 支持模块间的路由、通信、解耦

什么是路由?可能你首先联想到的是路由器,路由器根据路由表来转发数据包,路由表决定了数据传输的路径。ARouter就相当于一个路由器,让无依赖的双方可以通信。

下面我们就通过源码来分析ARouter的实现原理,本文ARouter源码基于:com.alibaba:arouter-api:1.5.2。

源码解析

ARouter的简单使用在前面的文章中已有介绍,使用前需要在Application中用ARouter.init(this)来初始化ARouter,下面我们就从这里开始分析源码:

java 复制代码
public final class ARouter {

	 private volatile static boolean hasInit = false;

    public static void init(Application application) {
        //判断是否已经初始化
        if (!hasInit) {
            ...
            //调用_ARouter.init()初始化
            hasInit = _ARouter.init(application);

            if (hasInit) {
                _ARouter.afterInit();
            }
            ...
        }
    }
}

首先判断有没有初始化过,已经初始化过后把hasInit置为true,ARouter的init()方法调用了_ARouter的init()方法:

java 复制代码
final class _ARouter {

	 private volatile static boolean hasInit = false;
	
	 private volatile static ThreadPoolExecutor executor = DefaultPoolExecutor.getInstance();
	
    protected static synchronized boolean init(Application application) {
        mContext = application;
        LogisticsCenter.init(mContext, executor);
        ...
        hasInit = true;
        return true;
    }
}

_ARouter的init()方法中出现了LogisticsCenter这个类,这个类是做什么的呢?看类名是 "物流中心"的意思 ,继续看下它的init()方法做了些什么:

java 复制代码
public class LogisticsCenter {

   //加载内存中的路由信息 
	public synchronized static void init(Context context, ThreadPoolExecutor tpe) throws HandlerException {
        ...
        //从插件中加载路由表
        loadRouterMap();
        if (registerByPlugin) {
            logger.info(TAG, "Load router map by arouter-auto-register plugin.");
        } else {
            Set<String> routerMap;

            // It will rebuild router map every times when debuggable.
            // 调用ARouter.openDebug()、第一次运行、版本更新都会更新routerMap。
            // PackageUtils.isNewVersion()中通过SharedPreference存储的App的
            // versionName和versionCode来判断版本是否有更新
            if (ARouter.debuggable() || PackageUtils.isNewVersion(context)) {

                // These class was generated by arouter-compiler.
                // 这里就是从dex中获取com.alibaba.android.arouter.routes包下的.class
                // ROUTE_ROOT_PAKCAGE = "com.alibaba.android.arouter.routes";
                routerMap = ClassUtils.getFileNameByPackageName(mContext, ROUTE_ROOT_PAKCAGE);
                if (!routerMap.isEmpty()) {
                    //把routerMap存入SharedPreference
                    context.getSharedPreferences(AROUTER_SP_CACHE_KEY, Context.MODE_PRIVATE).edit().putStringSet(AROUTER_SP_KEY_MAP, routerMap).apply();
                }
				
		     //更新SharedPreference中存储的App的版本信息
                PackageUtils.updateVersion(context);    // Save new version name when router map update finishes.
            } else {
                //直接从SP中拿routerMap,就是前面保存在SP中的routerMap
                routerMap = new HashSet<>(context.getSharedPreferences(AROUTER_SP_CACHE_KEY, Context.MODE_PRIVATE).getStringSet(AROUTER_SP_KEY_MAP, new HashSet<String>()));
            }
		 ...
            // 根据className,来实例化不同的对象并调用loadInto()方法。
            for (String className : routerMap) {
                //以com.alibaba.android.arouter.routes.ARouter$$Root开头
                if (className.startsWith(ROUTE_ROOT_PAKCAGE + DOT + SDK_NAME + SEPARATOR + SUFFIX_ROOT)) {
                    // This one of root elements, load root.
                    ((IRouteRoot) (Class.forName(className).getConstructor().newInstance())).loadInto(Warehouse.groupsIndex);
                } 
                //以com.alibaba.android.arouter.routes.ARouter$$Interceptors开头
                else if (className.startsWith(ROUTE_ROOT_PAKCAGE + DOT + SDK_NAME + SEPARATOR + SUFFIX_INTERCEPTORS)) {
                    // Load interceptorMeta
                    ((IInterceptorGroup) (Class.forName(className).getConstructor().newInstance())).loadInto(Warehouse.interceptorsIndex);
                } 
                //以com.alibaba.android.arouter.routes.ARouter$$Providers开头
                else if (className.startsWith(ROUTE_ROOT_PAKCAGE + DOT + SDK_NAME + SEPARATOR + SUFFIX_PROVIDERS)) {
                    // Load providerIndex
                    ((IProviderGroup) (Class.forName(className).getConstructor().newInstance())).loadInto(Warehouse.providersIndex);
                }
            }
        }
    }

	/**
     * arouter-auto-register plugin will generate code inside this method
     * call this method to register all Routers, Interceptors and Providers
     */
    private static void loadRouterMap() {
        registerByPlugin = false;
        // auto generate register code by gradle plugin: arouter-auto-register
        // looks like below:
        // registerRouteRoot(new ARouter..Root..modulejava());
        // registerRouteRoot(new ARouter..Root..modulekotlin());
    }
}

从上面的代码可以看出,这段代码就是加载路由表的核心代码,上面有注释标出了一些代码的业务逻辑,这里再挑出几个比较难理解的地方重点讲解一下,首先是这句代码loadRouterMap(),注释上说的是从插件中加载路由表,什么意思呢?就是如果我们想缩短ARouter初始化的时间,可以用ARouter的Gradle插件,这个插件能自动加载路由表,这样ARouter初始化的时候就不需要读取类的信息,从而缩短初始化时间。

第22行从dex中获取所有com.alibaba.android.arouter.routes包下的.class文件放入routerMap,从第36行开始遍历routerMap,分成了3种情况:

  1. 如果.class文件以com.alibaba.android.arouter.routes.ARouter$$Root开头则反射创建该实例,并调用该实例的loadInto()方法,loadInto()方法传入的参数为Warehouse.groupsIndex;
  2. 如果.class文件以com.alibaba.android.arouter.routes.ARouter$$Interceptors开头则反射创建该实例,并调用该实例的loadInto()方法,loadInto()方法传入的参数为Warehouse.interceptorsIndex;
  3. 如果.class文件以com.alibaba.android.arouter.routes.ARouter$$Providers开头则反射创建该实例,并调用该实例的loadInto()方法,loadInto()方法传入的参数为Warehouse.providersIndex;

在前面的文章中,选择集成调试模式,Sync Project后,在download模块下的build\intermediates\javac\debug\classes\com\alibaba\android\arouter\routes目录下共生成了3个.class文件,这些文件是由注解处理器生成的:

ruby 复制代码
ARouter$$Group$$download.class
ARouter$$Providers$$download.class
ARouter$$Root$$download.class

其中ARouter$$Root$$download.class以com.alibaba.android.arouter.routes.ARouter$$Root开头,点开ARouter$$Root$$download.class,其代码如下:

java 复制代码
public class ARouter$$Root$$download implements IRouteRoot {
  @Override
  public void loadInto(Map<String, Class<? extends IRouteGroup>> routes) {
    routes.put("download", ARouter$$Group$$download.class);
  }
}

其loadInto()方法将ARouter$$Group$$download.class存入routes中,routes为Map<String, Class<? extends IRouteGroup>>类型,key为download,routes就是上面的Warehouse.groupsIndex。Warehouse是存储路由信息的仓库,其代码如下:

java 复制代码
class Warehouse {
    //用于缓存实现了IRouteGroup接口的类,key为path的第一级
    static Map<String, Class<? extends IRouteGroup>> groupsIndex = new HashMap<>();
    //用于缓存路由元信息,key为path
    static Map<String, RouteMeta> routes = new HashMap<>();
    
    //缓存实现了IProvider接口的类的实例,key为IProvider实现类的class
    static Map<Class, IProvider> providers = new HashMap<>();
    //缓存实现了IProvider类的路由元信息,key为继承了IProvider的接口的路径
    static Map<String, RouteMeta> providersIndex = new HashMap<>();

    //缓存interceptor
    static Map<Integer, Class<? extends IInterceptor>> interceptorsIndex = new UniqueKeyTreeMap<>("More than one interceptors use same priority [%s]");
    static List<IInterceptor> interceptors = new ArrayList<>();

    static void clear() {
        routes.clear();
        groupsIndex.clear();
        providers.clear();
        providersIndex.clear();
        interceptors.clear();
        interceptorsIndex.clear();
    }
}

这样就把ARouter$$Group$$download.class存入了Warehouse.groupsIndex这个HashMap中,key为download。

接下来分析跳转的代码,跳转代码如下:

java 复制代码
ARouter.getInstance().build(path).navigation();

跟进去:

java 复制代码
public final class ARouter {

    private volatile static ARouter instance = null;

    private ARouter() {
    }

    public static ARouter getInstance() {
        if (!hasInit) {
            throw new InitException("ARouter::Init::Invoke init(context) first!");
        } else {
            if (instance == null) {
                synchronized (ARouter.class) {
                    if (instance == null) {
                        instance = new ARouter();
                    }
                }
            }
            return instance;
        }
    }
    
    public Postcard build(String path) {
        return _ARouter.getInstance().build(path);
    }
    
}

在getInstance()方法中先判断hasInit是否为true,如果为false抛出异常,接下来用单例模式创建ARouter实例,build()方法调用了_ARouter.getInstance().build(path):

java 复制代码
final class _ARouter {

    private _ARouter() {
    }

    protected static _ARouter getInstance() {
        if (!hasInit) {
            throw new InitException("ARouterCore::Init::Invoke init(context) first!");
        } else {
            if (instance == null) {
                synchronized (_ARouter.class) {
                    if (instance == null) {
                        instance = new _ARouter();
                    }
                }
            }
            return instance;
        }
    }
    
    /**
     * Build postcard by path and default group
     */
    protected Postcard build(String path) {
        if (TextUtils.isEmpty(path)) {
            throw new HandlerException(Consts.TAG + "Parameter is invalid!");
        } else {
            PathReplaceService pService = ARouter.getInstance().navigation(PathReplaceService.class);
            if (null != pService) {
                //替换path
                path = pService.forString(path);
            }
            return build(path, extractGroup(path), true);
        }
    }
    
    //从传入的path中解析出group
    private String extractGroup(String path) {
        ...
        String defaultGroup = path.substring(1, path.indexOf("/", 1));
        if (TextUtils.isEmpty(defaultGroup)) {
            throw new HandlerException(Consts.TAG + "Extract the default group failed! There's nothing between 2 '/'!");
        } else {
            return defaultGroup;
        }
        ...
    }
    
    /**
     * Build postcard by path and group
     * 通过path和group拿到Postcard
     */
    protected Postcard build(String path, String group, Boolean afterReplace){
        ...
        return new Postcard(path, group);
    }

}

第25行先判断,如果path为空抛出异常,如果想替换path,可以写一个类实现PathReplaceService接口。接着调用第33行的build()方法,这里从path中解析出group,最后新建Postcard实例,传入path和group。Postcard从名字翻译过来是明信片的意思,其承载了一次路由需要的所有信息,Postcard代码如下:

java 复制代码
/**
 * A container that contains the roadmap.
 */
public final class Postcard extends RouteMeta {

    private Uri uri;
    private Bundle mBundle;         // Data to transform

    public Postcard() {
        this(null, null);
    }

    public Postcard(String path, String group) {
        this(path, group, null, null);
    }

    public Postcard(String path, String group, Uri uri, Bundle bundle) {
        setPath(path);
        setGroup(group);
        setUri(uri);
        this.mBundle = (null == bundle ? new Bundle() : bundle);
    }
   

    /**
     * Navigation to the route with path in postcard.
     * No param, will be use application context.
     */
    public Object navigation() {
        return navigation(null);
    }

    /**
     * Navigation to the route with path in postcard.
     *
     * @param context Activity and so on.
     */
    public Object navigation(Context context) {
        return navigation(context, null);
    }

    /**
     * Navigation to the route with path in postcard.
     *
     * @param context Activity and so on.
     */
    public Object navigation(Context context, NavigationCallback callback) {
        return ARouter.getInstance().navigation(context, this, -1, callback);
    }
}

Postcard继承自路由元信息RouteMeta,传入path和group实际调用的是RouteMeta的setPath()方法和setGroup()方法,对RouteMeta中的path和group成员变量赋值:

java 复制代码
/**
 * It contains basic route information.
 */
public class RouteMeta {
    private RouteType type;         // Type of route
    private Element rawType;        // Raw type of route
    private Class<?> destination;   // Destination
    private String path;            // Path of route
    private String group;           // Group of route
    
    public String getPath() {
        return path;
    }

    public RouteMeta setPath(String path) {
        this.path = path;
        return this;
    }

    public String getGroup() {
        return group;
    }

    public RouteMeta setGroup(String group) {
        this.group = group;
        return this;
    }
}

接下来看看navigation()方法做了什么?

java 复制代码
public final class Postcard extends RouteMeta {

    public Object navigation() {
        return navigation(null);
    }

    /**
     * Navigation to the route with path in postcard.
     *
     * @param context Activity and so on.
     */
    public Object navigation(Context context) {
        return navigation(context, null);
    }

    /**
     * Navigation to the route with path in postcard.
     *
     * @param context Activity and so on.
     */
    public Object navigation(Context context, NavigationCallback callback) {
        return ARouter.getInstance().navigation(context, this, -1, callback);
    }
}    

调用了ARouter的navigation()方法并传入Postcard实例:

java 复制代码
public final class ARouter {

    /**
     * Launch the navigation.
     */
    public Object navigation(Context mContext, Postcard postcard, int requestCode, NavigationCallback callback) {
        return _ARouter.getInstance().navigation(mContext, postcard, requestCode, callback);
    }
}

这里又调用了_ARouter的navigation()方法:

java 复制代码
final class _ARouter {
    /**
     * Use router navigation.
     */
    protected Object navigation(final Context context, final Postcard postcard, final int requestCode, final NavigationCallback callback) {
        //若有PretreatmentService的实现,就进行预处理,可以在真正路由前进行一些判断然后中断路由。
        PretreatmentService pretreatmentService = ARouter.getInstance().navigation(PretreatmentService.class);
        if (null != pretreatmentService && !pretreatmentService.onPretreatment(context, postcard)) {
            // Pretreatment failed, navigation canceled.
            return null;
        }

        // Set context to postcard.
        postcard.setContext(null == context ? mContext : context);

        try {
            //标记1,完善postcard信息
            LogisticsCenter.completion(postcard);
        } catch (NoRouteFoundException ex) {
            logger.warning(Consts.TAG, ex.getMessage());

            //没有找到路由的回调
            if (null != callback) {
                callback.onLost(postcard);
            } else {
                // No callback for this invoke, then we use the global degrade service.
                DegradeService degradeService = ARouter.getInstance().navigation(DegradeService.class);
                if (null != degradeService) {
                    degradeService.onLost(context, postcard);
                }
            }

            return null;
        }

        //找到路由的回调
        if (null != callback) {
            callback.onFound(postcard);
        }

        //不是绿色通道,要走拦截器
        if (!postcard.isGreenChannel()) {   // It must be run in async thread, maybe interceptor cost too mush time made ANR.
            interceptorService.doInterceptions(postcard, new InterceptorCallback() {
                /**
                 * Continue process
                 *
                 * @param postcard route meta
                 */
                 //拦截器处理结果:继续路由
                @Override
                public void onContinue(Postcard postcard) {
                    _navigation(postcard, requestCode, callback);
                }

                /**
                 * Interrupt process, pipeline will be destory when this method called.
                 *
                 * @param exception Reson of interrupt.
                 */
                 //拦截器处理结果:中断路由,回调中断
                @Override
                public void onInterrupt(Throwable exception) {
                    if (null != callback) {
                        callback.onInterrupt(postcard);
                    }

                    logger.info(Consts.TAG, "Navigation failed, termination by interceptor : " + exception.getMessage());
                }
            });
        } else {
            //标记2,绿色通道,不走拦截器,获取路由结果
            return _navigation(postcard, requestCode, callback);
        }

        return null;
    }
}

我们先看看标记1处代码做了什么:

java 复制代码
public class LogisticsCenter {

    /**
     * Completion the postcard by route metas
     *
     * @param postcard Incomplete postcard, should complete by this method.
     */
    public synchronized static void completion(Postcard postcard) {
        //通过path去Warehouse.routes中拿RouteMeta
        RouteMeta routeMeta = Warehouse.routes.get(postcard.getPath());
        //如果RouteMeta为空
        if (null == routeMeta) {
            // Maybe its does't exist, or didn't load.
            //如果Warehouse.groupsIndex中没有这个group,抛出异常
            if (!Warehouse.groupsIndex.containsKey(postcard.getGroup())) {
                throw new NoRouteFoundException(TAG + "There is no route match the path [" + postcard.getPath() + "], in group [" + postcard.getGroup() + "]");
            } else {
                // Load route and cache it into memory, then delete from metas.
                //添加RouteMeta到Warehouse.routes
                addRouteGroupDynamic(postcard.getGroup(), null);
                
                //重新执行completion()方法
                completion(postcard);   // Reload
            }
        } else {
            //把routeMeta里面的配置传递给postcard
            postcard.setDestination(routeMeta.getDestination());
            postcard.setType(routeMeta.getType());
            postcard.setPriority(routeMeta.getPriority());
            postcard.setExtra(routeMeta.getExtra());

            Uri rawUri = postcard.getUri();
            if (null != rawUri) {   // Try to set params into bundle.
                Map<String, String> resultMap = TextUtils.splitQueryParameters(rawUri);
                Map<String, Integer> paramsType = routeMeta.getParamsType();

                if (MapUtils.isNotEmpty(paramsType)) {
                    // Set value by its type, just for params which annotation by @Param
                    for (Map.Entry<String, Integer> params : paramsType.entrySet()) {
                        setValue(postcard,
                                params.getValue(),
                                params.getKey(),
                                resultMap.get(params.getKey()));
                    }

                    // Save params name which need auto inject.
                    postcard.getExtras().putStringArray(ARouter.AUTO_INJECT, paramsType.keySet().toArray(new String[]{}));
                }

                // Save raw uri
                postcard.withString(ARouter.RAW_URI, rawUri.toString());
            }

            switch (routeMeta.getType()) {
                //如果类型是PROVIDER
                case PROVIDER:  // if the route is provider, should find its instance
                    // Its provider, so it must implement IProvider
                    Class<? extends IProvider> providerMeta = (Class<? extends IProvider>) routeMeta.getDestination();
                    //去Warehouse.providers中获取实例
                    IProvider instance = Warehouse.providers.get(providerMeta);
                    //如果instance为null
                    if (null == instance) { // There's no instance of this provider
                        IProvider provider;
                        try {
                            //反射创建实例
                            provider = providerMeta.getConstructor().newInstance();
                            provider.init(mContext);
                            //存入Warehouse.providers
                            Warehouse.providers.put(providerMeta, provider);
                            instance = provider;
                        } catch (Exception e) {
                            logger.error(TAG, "Init provider failed!", e);
                            throw new HandlerException("Init provider failed!");
                        }
                    }
                    //传递给postcard
                    postcard.setProvider(instance);
                    postcard.greenChannel();    // Provider should skip all of interceptors
                    break;
                case FRAGMENT:
                    postcard.greenChannel();    // Fragment needn't interceptors
                default:
                    break;
            }
        }
    }
    
    public synchronized static void addRouteGroupDynamic(String groupName, IRouteGroup group) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        if (Warehouse.groupsIndex.containsKey(groupName)){
            // If this group is included, but it has not been loaded
            // load this group first, because dynamic route has high priority.
            //调用实例的loadInto()方法添加到Warehouse.routes  
            Warehouse.groupsIndex.get(groupName).getConstructor().newInstance().loadInto(Warehouse.routes);
            //从Warehouse.groupsIndex中移除key为groupName的数据
            Warehouse.groupsIndex.remove(groupName);
        }

        // cover old group.
        if (null != group) {
            group.loadInto(Warehouse.routes);
        }
    }
}    

上面的completion()方法先去Warehouse.routes中拿RouteMeta,Warehouse就是前面存储路由信息的仓库,Warehouse.routes是其中缓存路由元信息的HashMap。判断如果RouteMeta为null,调用addRouteGroupDynamic()方法,去Warehouse.groupsIndex中取出缓存的类,使用反射创建该类的实例,然后调用其loadInto()方法,这样就调用了ARouter$$Group$$download.class的loadInto()方法,其代码如下:

java 复制代码
public class ARouter$$Group$$download implements IRouteGroup {
  @Override
  public void loadInto(Map<String, RouteMeta> atlas) {
    atlas.put("/download/DownloadActivity", RouteMeta.build(RouteType.ACTIVITY, DownloadActivity.class, "/download/downloadactivity", "download", null, -1, -2147483648));
    atlas.put("/download/service", RouteMeta.build(RouteType.PROVIDER, DownloadServiceImpl.class, "/download/service", "download", null, -1, -2147483648));
  }
}

其loadInto()方法把RouteMeta通过build()方法创建的实例添加到Warehouse.routes中。

回到前面的代码,接着又调用了一次completion()方法,此时routeMeta不再为null,将routeMeta的配置信息(destination、type等信息)传递给postcard,如果routeMeta类型是PROVIDER,会反射创建实例并存入Warehouse.providers。最后执行标记2处的代码开始准备跳转,标记2处代码如下:

java 复制代码
final class _ARouter {

    private Object _navigation(final Postcard postcard, final int requestCode, final NavigationCallback callback) {
        final Context currentContext = postcard.getContext();

        switch (postcard.getType()) {
            case ACTIVITY:
                // Build intent
                final Intent intent = new Intent(currentContext, postcard.getDestination());
                intent.putExtras(postcard.getExtras());

                // Set flags.
                int flags = postcard.getFlags();
                if (0 != flags) {
                    intent.setFlags(flags);
                }

                // Non activity, need FLAG_ACTIVITY_NEW_TASK
                if (!(currentContext instanceof Activity)) {
                    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                }

                // Set Actions
                String action = postcard.getAction();
                if (!TextUtils.isEmpty(action)) {
                    intent.setAction(action);
                }

                // Navigation in main looper.
                runInMainThread(new Runnable() {
                    @Override
                    public void run() {
                        startActivity(requestCode, currentContext, intent, postcard, callback);
                    }
                });

                break;
            case PROVIDER:
                return postcard.getProvider();
            case BOARDCAST:
            case CONTENT_PROVIDER:
            case FRAGMENT:
                //Broadcast、ContentProvider、Fragment,都是使用postcard.getDestination()反射创建实例
                Class<?> fragmentMeta = postcard.getDestination();
                try {
                    Object instance = fragmentMeta.getConstructor().newInstance();
                    if (instance instanceof Fragment) {
                        ((Fragment) instance).setArguments(postcard.getExtras());
                    } else if (instance instanceof android.support.v4.app.Fragment) {
                        ((android.support.v4.app.Fragment) instance).setArguments(postcard.getExtras());
                    }

                    return instance;
                } catch (Exception ex) {
                    logger.error(Consts.TAG, "Fetch fragment instance error, " + TextUtils.formatStackTrace(ex.getStackTrace()));
                }
            case METHOD:
            case SERVICE:
            default:
                return null;
        }

        return null;
    }
}

上面的代码没什么好说的,先判断postcard的类型,如果是Activity类型,就把postcard里面的参数传递给Intent,最终还是调用startActivity()方法进行跳转;如果是Provider类型,直接通过postcard.getProvider()拿到IProvier的实现类的单例;Broadcast、ContentProvider、Fragment都是使用postcard.getDestination()反射创建实例并返回。

参考资料: juejin.cn/post/720004...

相关推荐
前端大卫2 小时前
Vue3 + Element-Plus 自定义虚拟表格滚动实现方案【附源码】
前端
却尘2 小时前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare2 小时前
浅浅看一下设计模式
前端
Lee川2 小时前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix3 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人3 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl3 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人3 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼3 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端