基于 webpack 实现点击 vue 页面元素跳转到对应的 vscode 代码

开发 前端
本文以一个点击 vue 页面元素跳转到对应 vscode 代码的 loader 和 plugin 开发实战,讲述 webpack loader 和 plugin 开发的简单入门。

观众收益

通过本文,你可以对 webpack 的 loader 和 plugin 有一个更清晰的认知,以及如何开发一个 loader 和 plugin,同时也穿插了一些 vue、css、node 方面的一些相关知识,扩充你的知识面。

效果

先上效果:

源码仓库

前置知识

由于是开发 loader 和 plugin,所以需要对 loader 和 plugin 的作用及构成需要有一些简单的理解。

loader

作用

loader 是 webpack 用来将不同类型的文件转换为 webpack 可识别模块的工具。我们都知道 webpack 默认只支持 js 和 json 文件的处理,通过 loader 我们可以将其他格式的文件转换为 js 格式,让 webpack 进行处理。除此之外,我们也可以通过 loader 对文件的内容进行一定的加工和处理。

构成

loader 本质上就是导出一个 JavaScript 函数,webpack 会通过 loader runner[1] 会调用此函数,然后将上一个 loader 产生的结果或者资源文件传入进去。

example:

// 同步 loader

/**

 * @param {string|Buffer} content 源文件的内容

 * @param {object} [map] 可以被 https://github.com/mozilla/source-map 使用的 SourceMap 数据

 * @param {any} [meta] meta 数据,可以是任何内容

 */

module.exports = function (contentmapmeta) {

  return someSyncOperation(content);

};
// or 

module.exports = function (contentmapmeta) {

  this.callback(nullsomeSyncOperation(content), mapmeta);

  return// 当调用 callback() 函数时,总是返回 undefined

};

// --------------------------------------------------------------------------

// 异步 loader

module.exports = function (contentmapmeta) {

  var callback = this.async();

  someAsyncOperation(contentfunction (errresult) {

    if (errreturn callback(err);

    callback(nullresultmapmeta);

  });

};

// or 

module.exports = function (contentmapmeta) {

  var callback = this.async();

  someAsyncOperation(contentfunction (errresultsourceMapsmeta) {

    if (errreturn callback(err);

    callback(nullresultsourceMapsmeta);

  });
};

参考 api

https://webpack.docschina.org/api/loaders/

plugin

作用

拓展 webpack 功能,提供一切 loader 无法完成的功能。

构成

一个 plugin 由以下部分组成:

  • 导出一个 JavaScript 具名函数或 JavaScript 类。
  • 在插件函数的 prototype 上定义一个 ​​apply​​ 方法。
  • 指定一个绑定到 webpack 自身的事件钩子[2]
  • 处理 webpack 内部实例的特定数据。
  • 功能完成后调用 webpack 提供的回调。
// 一个 JavaScript 类

class MyExampleWebpackPlugin {

  // 在插件函数的 prototype 上定义一个 `apply` 方法,以 compiler 为参数。

  apply(compiler) {

    // 指定一个挂载到 webpack 自身的事件钩子。

    compiler.hooks.emit.tapAsync(

      'MyExampleWebpackPlugin',

      (compilationcallback=> {

        console.log('这是一个示例插件!');

        console.log(

          '这里表示了资源的单次构建的 `compilation` 对象:',

          compilation

        );



        // 用 webpack 提供的插件 API 处理构建过程

        compilation.addModule(/* ... */);



        callback();

      }

    );

  }

}

module.exports = MyExampleWebpackPlugin;

compiler 和 compliation

webpack plugin 开发中有两个重要的概念:compiler 和 compliation。

plugin 类中有一个 ​​apply​​ 方法,其接收 compiler 为参数, compiler[3] 在 webpack 构建之初就已经创建,并且贯穿 webpack 整个生命周期,其包含了 webpack 配置文件传递的所有选项,例如 loader、plugins 等信息。

compilation[4] 是到准备编译模块时,才会创建 compilation 对象。其包含了模块资源、编译生成资源以及变化的文件和被跟踪依赖的状态信息等等,以供插件工作时使用。如果我们在插件中需要完成一个自定义的编译过程,那么必然会用到这个对象。

参考 api

https://webpack.docschina.org/api/plugins/

整体思路

  1. 要做到点击元素能够跳转 vscode,首先需要某种手段打开 vscode,借助一个 plugin 实现如下功能:
  • 打开 vscode:借助 react 封装的 launchEditor[5] 方法,可以识别各种编辑器并唤醒,原理是通过 node 的 child_process api 去启动 vscode
  • 点击元素时通知跳转:在本地启动一个 node server 服务,点击元素时发送一个请求,然后 node server 去触发跳转
  1. 要能够跳转到 vscode 对应的代码行和列,需要知道点击的元素对应的源码位置,所以需要一个 loader,在编译上将源码的相关信息注入到 dom 上

实现过程

实现 vnode -loader

调试

借助 loader-runner 调试

我们在开发 loader 的过程中,往往需要打断点或者打印部分信息来进行调试,但是如果每次都启动 webpack,可能存在启动速度慢、项目文件太多需要过滤信息等诸多问题。这里我们可以借助前面提到的 loader runner[6] ,方便地进行调试。

​loader-runner​​ 这个包中导出了一个名为 ​​runLoaders​​ 的方法,事实上 webpack 内部也是借助这个方法去运行各种 loader 的。它接收 4个参数:

  • resource:要解析的资源的绝对路径
  • loaders:要使用的 loader 的绝对路径数组
  • context:对 loader 附加的上下文
  • readResource:读取资源的函数

在根目录下新建一个 ​​run-loader.js​​ 文件,填入如下内容,执行 ​​node ./run-loader​​ 指令即可运行 loader,并可以在 loader 源码中进行断点调试:

const { runLoaders } = require('loader-runner');

const fs = require('fs');

const path = require('path');



runLoaders(

  {

    resourcepath.resolve(__dirname'./src/App.vue'),

    loaders: [path.resolve(__dirname'./node_modules/vnode-loader')],

    context: {

      minimizetrue,

    },

    readResourcefs.readFile.bind(fs),

  },

  (errres=> {

    if (err) {

      console.log(err);

      return;

    }

    console.log(res);

  }

);

在 vue-cli 中调试

由于我们是在 vue 项目中使用,所以为了配合 vue 的真实环境,我们通过 vue-cli 的webpack 配置来调试 loader。

新建 ​​.vscode/launch.json​​ 文件,添加如下内容,下面的内容指定了在 5858 端口,执行 ​​npm run debug​​ 命令启动一个 node 服务:

{

  // 使用 IntelliSense 了解相关属性。

  // 悬停以查看现有属性的描述。

  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387

  "version""0.2.0",

  "configurations": [

    {

      "type""node",

      "request""launch",

      "name""debug",

      "skipFiles": ["<node_internals>/**"],

      "runtimeExecutable""npm",

      "runtimeArgs": ["run""debug"],

      "port"5858

    }

  ]

}

在 ​​package.json​​ 文件中添加如下命令:

{

  "name""loader-test",

  "version""0.1.0",

  "private"true,

  "scripts": {

    "serve""vue-cli-service serve",

    "build""vue-cli-service build",

    "lint""vue-cli-service lint",

    "debug""node --inspect-brk=5858 ./node_modules/@vue/cli-service/bin/vue-cli-service.js serve"

  },

  // ...

}

点击 vscode 的 debug,即可进行调试:

解析 template

我们要往 dom 上注入源码信息,所以首先需要获取 .vue 文件的 dom 结构。那我们就需要对 template 的部分进行解析,这里我们可以借助 @vue/compiler-sfc[7] 这个包去解析 .vue 文件。

import { parse } from '@vue/compiler-sfc';

import { LoaderContext } from 'webpack';

import { getInjectContent } from './inject-ast';



/**

 * @description inject line、column and path to VNode when webpack compiling .vue file

 * @type webpack.loader.Loader

 */

function TrackCodeLoader(thisLoaderContext<any>contentstring) {

  const filePath = this.resourcePath// 当前文件的绝对路径

  let params = new URLSearchParams(this.resource);

  if (params.get('type'=== 'template') {

    const vueParserContent = parse(content); // vue文件parse后的内容

    const domAst = vueParserContent.descriptor.template.ast// template开始的dom ast结构

    const templateSource = domAst.loc.source// template部分的原字符串

    const newTemplateSource = getInjectContent(

      domAst,

      templateSource,

      filePath

    ); // 注入后的template部分字符串

    const newContent = content.replace(templateSourcenewTemplateSource);

    return newContent;

  } else {

    return content;

  }

}



export = TrackCodeLoader;

我们对上面部分代码进行分析,首先我们导出了一个 ​​TrackCodeLoader​​ 函数,这是 vnode -loader 的入口函数,通过 ​​this​​ 对象,我们能拿到诸多 webpack 及源代码的相关信息。

看这一句代码:​​params.get('type') === 'template'​​ ,对于 .vue 文件,vue-loader 会将其分解为多部分区交给其实现的解析器解析。例如现在有一个文件路径为 ​​/system/project/app.vue​​,vue-loader 会将其解析为三部分:

  • ​/system/project/app.vue?type=template&xxx​​:这部分作为 html 部分,将来由 vue 内置的 ​​vue-template-es2015-compiler​​ 去解析为 dom
  • ​/system/project/app.vue?type=script&lang=js&xxx​​:这部分作为 js 部分,将来交给匹配了 webpack 配置的 ​​/.js$/​​ rule 的 ​​babel-loader​​ 等 loader 去处理
  • ​/system/project/app.vue?type=style&lang=css&xxx​​:这部分作为 css,将来交给匹配了 webpack 配置的 ​​/.css$/​​ rule 的 ​​css-loader​​、​​style-loader​​ 等去处理

所以一个 vue 文件,实际上会多次经过我们这个自定义的 loader,而我们只需要对其 url 中 type 参数为 ​​template​​ 的那一次进行处理,因为只有此次的 template 部分代码最终会被有效处理为 dom。

然后我们将 .vue 文件的内容作为参数传给 ​​@vue/compiler-sfc​​ 中导出的 parse 函数,我们得到了一个对象,对象中有一个 descriptor 属性,我们通过打一个断点可以看到,里面包含了 template、script、css 等几部分的 ast 解析结果:

现在我们已经获取到了 template 结构的 ast,我们要做的就是将 .vue 文件的 content,其中的 ​​domAst.loc.source​​ 部分替换为注入了源码信息的 template 字符串。

template 的 ast 是一个树状结构,表示当前的 dom 节点,和我们注入源码信息有关的主要是以下几个属性:

  • type:当前的节点类型,为 1 时表示标签节点,为 2 时表示文本节点,为 6 时表示标签属性……这里我们只需要对标签节点进行注入,也就是说只需要对 ​​type === 1​​ 的 ast 节点进行处理。
  • loc:当前节点在 vscode 中的信息,包括节点中在 vscode 中的源码信息、在 vscode 中起始和结束的行、列以及长度等。这一部分就是我们要注入的信息
  • childern:对子节点进行递归处理

注入源码信息

我们创建一个 ​​getInjectContent​​ 方法,将源码信息注入到 dom 中,​​getInjectContent​​ 接受三个参数:

  • ast:当前节点的 ast
  • source:当前节点对应的源码字符串
  • filePath:当前文件的绝对路径

在 dom 标签上注入行、列、标签名和文件路径等相关的信息:

export function getInjectContent(

  astElementNode,

  sourcestring,

  filePathstring

) {

  // type为1是为标签节点

  if (ast?.type === 1) {

    // 递归处理子节点

    if (ast.children && ast.children.length) {

      // 从最后一个子节点开始处理,防止同一行多节点影响前面节点的代码位置

      for (let i = ast.children.length - 1i >= 0i--) {

        const node = ast.children[ias ElementNode;

        source = getInjectContent(nodesourcefilePath);

      }

    }

    const codeLines = source.split('\n'); // 把行以\n划分方便注入

    const line = ast.loc.start.line// 当前节点起始行

    const column = ast.loc.start.column// 当前节点起始列

    const columnToInject = column + ast.tag.length// 要注入信息的列(标签名后空一格)

    const targetLine = codeLines[line - 1]; // 要注入信息的行

    const nodeName = ast.tag;

    const newLine =

      targetLine.slice(0columnToInject+

      ` ${InjectLineName}="${line}" ${InjectColumnName}="${column}" ${InjectPathName}="${filePath}" ${InjectNodeName}="${nodeName}"` +

      targetLine.slice(columnToInject);

    codeLines[line - 1= newLine// 替换注入后的内容

    source = codeLines.join('\n');

  }

  return source;

}

实现 vnode-plugin

node server 唤醒 vscode

我们通过 ​​http.createServer​​ ,创建一个本地的 node 服务,然后通过 ​​protfinder​​ 这个包,从 4000 端口开始寻找一个可用的接口启动服务。node 的本地服务接收 ​​file​​、​​line​​ 和 ​​column​​ 三个参数,当收到请求时,通过从 ​​launchEditor​​ 唤醒 vscode 并打开对应的代码位置。

值得注意的是 webpack 每次编译都会重新生成一个 compliation 对象,都会运行一次 plugin,所以我们需要通过一个 ​​started​​ 标识记录一下当前是否有服务已经启动,防止服务启动多次。

​launchEditor​​ 是直接引用的 react 封装好的 launchEditor.js[8] 文件(将里面 ​​REACT_EDITOR​​ 改为 ​​VUE_EDITOR​​,方便后面配合 ​​.env.local​​ 使用),它本质上是通过 node 提供的 ​​child_process​​ 模块,识别系统中运行中的编辑器集成并自动打开,通过接收​​file​​、​​line​​ 和 ​​column​​ 三个参数,可以打开具体的文件位置及将光标定位到相应的行和列。

此部分代码如下:

// 启动本地接口,访问时唤起vscode

import http from 'http';

import portFinder from 'portfinder';

import launchEditor from './launch-editor';



let started = false;



export = function StartServer(callbackFunction) {

  if (started) {

    return;

  }

  started = true;

  const server = http.createServer((reqres=> {

    // 收到请求唤醒vscode

    const params = new URLSearchParams(req.url.slice(1));

    const file = params.get('file');

    const line = Number(params.get('line'));

    const column = Number(params.get('column'));

    res.writeHead(200, {

      'Access-Control-Allow-Origin''*',

      'Access-Control-Allow-Methods''*',

      'Access-Control-Allow-Headers':

        'Content-Type,XFILENAME,XFILECATEGORY,XFILESIZE,X-URL-PATH,x-access-token',

    });

    res.end('ok');

    launchEditor(filelinecolumn);

  });



  // 寻找可用接口

  portFinder.getPort({ port4000 }, (errErrorportnumber=> {

    if (err) {

      throw err;

    }

    server.listen(port, () => {

      callback(port);

    });

  });

};

控制功能的开关

我们需要能够控制点击元素跳转 vscode 这个功能的开启和关闭,控制开关的实现方式有很多,例如按键组合触发、悬浮窗……此处采用悬浮窗的控制方式。

在页面中添加一个固定定位的悬浮窗,我在插件实现的悬浮窗是可以拖拽移动的,拖拽和样式部分的代码不是重点,因为不在这里详细展开了,有兴趣的同学可以看源码了解。

悬浮窗的 dom 部分如下:(此部分代码后面都会通过 vnode-plugin 自动注入到 html 中,无需手动添加):

<div id="_vc-control-suspension" draggable="true">V</div>

我们用一个 ​​is_tracking​​ 变量作为功能是否打开的标识,当点击悬浮窗时,切换 ​​is_tracking​​ 的值,从而控制功能的开关(后面会提到):

// 功能是否开启

let is_tracking = false;



const suspension_control = document.getElementById(

  '_vc-control-suspension'

);

suspension_control.addEventListener('click'function (e) {

  if (!has_control_be_moved) {

    clickControl(e);

  } else {

    has_control_be_moved = false;

  }

});



// 功能开关

function clickControl(e) {

  let dom = e.target as HTMLElement;

  if (dom.id === '_vc-control-suspension') {

    if (is_tracking) {

      is_tracking = false;

      dom.style.backgroundColor = 'gray';

    } else {

      is_tracking = true;

      dom.style.backgroundColor = 'lightgreen';

    }

  }

}

移动鼠标时显示 dom 信息

我们在全局添加一个 ​​fixed​​ 定位的遮罩层,然后添加一个 ​​mousemove​​ 监听事件。

鼠标移动时,如果 ​​is_tracking​​ 为 ​​true​​,表示功能打开,通过 ​​e.path​​ ,我们可以找到鼠标悬浮的 dom 冒泡数组。取第一个注入了 ​​_vc-path​​ 属性的 dom,然后通过 ​​setCover​​ 方法在 dom 上展示遮罩层。

​setCover​​ 方法主要是将遮罩层定位到目标 dom 上,并设置遮罩层的大小和目标 dom 一样大,以及展示目标 dom 的标签、绝对路径等信息(类似 Chrome 调试时查看 dom 的效果)。

此部分代码如下:

// 鼠标移动时

window.addEventListener('mousemove'function (e) {

  if (is_tracking) {

    const nodePath = (e as any).path;

    let targetNode;

    if (nodePath[0].id === '_vc-control-suspension') {

      resetCover();

    }

    // 寻找第一个有_vc-path属性的元素

    for (let i = 0i < nodePath.lengthi++) {

      const node = nodePath[i];

      if (node.hasAttribute && node.hasAttribute('__FILE__')) {

        targetNode = node;

        break;

      }

    }

    if (targetNode) {

      setCover(targetNode);

    }

  }

});



// 鼠标移到有对应信息组件时,显示遮罩层

function setCover(targetNode) {

  const coverDom = document.querySelector('#__COVER__'as HTMLElement;

  const targetLocation = targetNode.getBoundingClientRect();

  const browserHeight = document.documentElement.clientHeight// 浏览器高度

  const browserWidth = document.documentElement.clientWidth// 浏览器宽度

  coverDom.style.top = `${targetLocation.top}px`;

  coverDom.style.left = `${targetLocation.left}px`;

  coverDom.style.width = `${targetLocation.width}px`;

  coverDom.style.height = `${targetLocation.height}px`;

  const bottom = browserHeight - targetLocation.top - targetLocation.height// 距浏览器视口底部距离

  const right = browserWidth - targetLocation.left - targetLocation.width// 距浏览器右边距离

  const file = targetNode.getAttribute('_vs-path');

  const node = targetNode.getAttribute('_vc-node');

  const coverInfoDom = document.querySelector('#__COVERINFO__'as HTMLElement;

  const classInfoVertical =

    targetLocation.top > bottom

      ? targetLocation.top < 100

        ? '_vc-top-inner-info'

        : '_vc-top-info'

      : bottom < 100

      ? '_vc-bottom-inner-info'

      : '_vc-bottom-info';

  const classInfoHorizon =

    targetLocation.left >= right ? '_vc-left-info' : '_vc-right-info';

  const classList = targetNode.classList;

  let classListSpans = '';

  classList.forEach((item=> {

    classListSpans += ` <span class="_vc-node-class-name">.${item}</span>`;

  });

  coverInfoDom.className = `_vc-cover-info ${classInfoHorizon} ${classInfoVertical}`;

  coverInfoDom.innerHTML = `<div><span class="_vc-node-name">${node}</span>${classListSpans}<div/><div>${file}</div>`;

}

点击遮罩层发送请求

在 window 上添加点击事件设置为捕获阶段(如果是冒泡阶段,会率先发生元素绑定的点击事件,影响我们的点击)。如果 ​​is_tracking​​ 为 true,则根据 ​​e.path​​ 找到第一个注入了源码信息的目标元素,调用 ​​trackCode​​ 方法发送请求唤醒 vscode。同时要通过 ​​e.stopPropagation()​​ 和 ​​e.preventDefault()​​ 阻止冒泡事件和元素默认的点击事件的发生。

​trackCode​​ 中主要是拿到目标 dom 上注入的源码信息,然后解析为参数,去请求我们前面启动的 node server 服务,node server 会通过 ​​launchEditor​​ 去打开 vscode。

此部分代码如下:

// 按下对应功能键点击页面时,在捕获阶段

window.addEventListener(

  'click',

  function (e) {

    if (is_tracking) {

      const nodePath = (e as any).path;

      let targetNode;

      // 寻找第一个有_vc-path属性的元素

      for (let i = 0i < nodePath.lengthi++) {

        const node = nodePath[i];

        if (node.hasAttribute && node.hasAttribute('__FILE__')) {

          targetNode = node;

          break;

        }

      }

      if (targetNode) {

        // 阻止冒泡

        e.stopPropagation();

        // 阻止默认事件

        e.preventDefault();

        // 唤醒 vscode

        trackCode(targetNode);

      }

    }

  },

  true

);



// 请求本地服务端,打开vscode

function trackCode(targetNode) {

  const file = targetNode.getAttribute('__FILE__');

  const line = targetNode.getAttribute('__LINE__');

  const column = targetNode.getAttribute('__COLUMN__');

  const url = `http://localhost:__PORT__/?file=${file}&line=${line}&column=${column}`;

  const xhr = new XMLHttpRequest();

  xhr.open('GET'urltrue);

  xhr.send();

}

在 html 中注入代码

最后我们要将上面的代码作为注入到 html 中,​​html-webpack-plugin​​ 提供了一个 ​​htmlWebpackPluginAfterHtmlProcessing​​ hook,我们可以在这个 hook 中在 body 最底下注入我们的代码:

import startServer from './server';

import injectCode from './get-inject-code';

class TrackCodePlugin {

  apply(complier) {

    complier.hooks.compilation.tap('TrackCodePlugin', (compilation=> {

      startServer((port=> {

        const code = injectCode(port);

        compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tap(

          'HtmlWebpackPlugin',

          (data=> {

            // html-webpack-plugin编译后的内容,注入代码

            data.html = data.html.replace('</body>'`${code}\n</body>`);

          }

        );

      });

    });

  }

}



export = TrackCodePlugin;

接入流程

可以根据以下流程在自己的 vue3 项目中接入体验一下:

  1. 安装​​vnode-loader​​和​​vnode-plugin​​:
yarn add vnode-loader vnode-plugin -D

  1. 修改​​vue.config.js​​,添加如下代码(一定要只用于开发环境):
// ...other code

module.exports = {

  // ...other code

  chainWebpack: (config=> {

    // ...other code

    if (process.env.NODE_ENV === 'development') {

      const VNodePlugin = require('vnode-plugin');

      config.module

        .rule('vue')

        .test(/.vue$/)

        .use('vnode-loader')

        .loader('vnode-loader')

        .end();

      config.plugin('vnode-plugin').use(new VNodePlugin());

    }

  }

};

  1. 在项目根目录添加一个名为​​.env.local​​ 的文件,内容如下:
editor

VUE_EDITOR=code

  1. 在vscode执行​​Command + Shift + P​​ ,输入​​shell Command: Install 'code' command in PATH​​并点击该命令:

显示如下弹窗表示成功:

性能

可以会有人担心插件会拖慢 webpack 打包编译的速度,经多次大项目对比测试,在使用该 loader 和plugin 的前后,webpack build和rebuild的速度几乎无差别,所以可以大胆接入。

总结

现在大家对 webpack 的 loader 和 plugin 开发应该有了一定的了解,借助自定义的 loader 和 plugin 确实能做许多超乎想象的事情(尤其是 plugin,很多时候只缺一个脑洞),大家可以发挥想象空间去编写一个自己的 loader 和 plugin,为项目开发提供助力。

参考

概念 | webpack 中文文档[9]

https://juejin.cn/post/6901466406823575560

参考资料

[1]loader runner: ​https://github.com/webpack/loader-runner

[2]事件钩子: ​https://webpack.docschina.org/api/compiler-hooks/

[3]compiler: ​https://webpack.docschina.org/api/node/#compiler-instance

[4]compilation: ​https://webpack.docschina.org/api/compilation-hooks/

[5]launchEditor: ​https://github.com/facebook/create-react-app/blob/main/packages/react-dev-utils/launchEditor.js

[6]loader runner: ​https://github.com/webpack/loader-runner

[7]@vue/compiler-sfc: ​https://github.com/vuejs/vue-next/tree/master/packages/compiler-sfc#readme

[8]launchEditor.js: ​https://github.com/facebook/create-react-app/blob/main/packages/react-dev-utils/launchEditor.js

[9]概念 | webpack 中文文档: ​https://webpack.docschina.org/concepts/

责任编辑:庞桂玉 来源: 前端大全
相关推荐

2022-11-08 08:53:56

插件IDE

2022-11-11 17:06:43

开发组件工具

2023-09-04 10:10:47

插件页面元素

2020-12-02 11:42:34

VSCode组件页面

2010-08-05 09:39:17

Flex页面跳转

2022-06-09 13:52:35

Vue协作开发项目

2009-07-03 17:24:31

Servlet页面跳转

2009-12-24 17:57:53

WPF页面跳转

2010-08-13 13:25:53

Flex页面跳转

2021-05-18 09:49:08

鸿蒙HarmonyOS应用

2009-07-02 09:25:41

JSP实现页面跳转

2009-12-02 20:02:18

PHP实现页面跳转

2009-12-02 19:42:24

PHP页面自动跳转

2009-12-16 17:24:26

Ruby on Rai

2012-04-19 16:41:24

Titanium视频实现页面跳转

2009-12-11 13:25:01

PHP页面跳转

2009-12-02 19:08:19

PHP跳转代码

2011-09-21 09:32:08

HTML 5

2017-04-28 09:18:39

webpackWeb搭建

2010-08-05 09:33:08

Flex页面跳转
点赞
收藏

51CTO技术栈公众号