引言
大家如果使用过移动端组件库(比如:Vant
),会发现在网站右侧有一个手机端的预览效果。
而且这个手机端预览的内容和外面的组件代码演示是同步的,切换组件的时候,移动端预览的内容也会发生相应的变化。
这是怎么实现的呢?我们一起来看看Vue DevUI组件库的实践吧!
先看下最终实现的效果动画:
1 方案选型
通过对竞品进行分析,发现移动端预览都是由嵌入的iframe
完成的,也就是说
移动端预览是一个完整的网站。
既然要做一个完整网站,在现有工程结构下想到了以下方案:
1.1 最初的设想
当前文档是通过vitepress
搭建的,并且vitepress
提供了定制主题的功能,开发完成的组件可以直接在md
文档中展示效果,所以就想到通过在vitepress
下新建一个mobile
路由来包含移动端预览所需的页面。
这会有一个问题,移动端预览页面不需要顶部导航栏和左侧菜单栏,调研发现,vitepress
仅支持定制一种主题,虽然貌似可以通过判断路由的方式在模板里面隐藏顶部导航栏和左侧菜单栏的方案(未验证是否可行),但是考虑到md
毕竟是来写文档的,虽然可以简单的展示组件效果,但是要做一些复杂的功能(比如目前需要的预览页面首页组件列表),在md
文档里面完成可能就不太合适了。
甚至如果以后要做的功能太复杂而无法在md
文档里面完成,那么当前的设计就面临着推倒重做的风险。
故排除此方案。
1.2 另一种更好的方案
最好是新起一个移动端预览的工程,并且技术栈与组件库保持一致vue3+vite
。
这样的话,组件效果可以在新工程里面直接预览,并且这种建站方案成熟,可以做很多事情,即使以后需要做比较复杂的演示效果也不会有大问题。
故采用此方案。
2 移动端工程搭建
2.1 新工程单独出来还是整合到组件库里面?
考虑到组件库应该是一个整体的工程,无论是组件开发、文档输出还是效果预览都应该在一个工程下,如果将移动端预览这部分单独到一个工程里面,会造成维护上的困难。那如何将移动端预览整合到组件库的工程里面呢?
通过在根目录新建mobile
文件夹,用来存放移动端预览工程相关的文件,根据vite
的配置,还需要新建一个mobile.html
入口文件,并在文件里面添加
<script type="module" src="/mobile/main.ts"></script>
开发环境下,可以通过
yarn run app:dev
启动,但是vite
默认将index.html
文件作为入口,所以在访问路径中增加/mobile.html
可以访问移动端预览的页面,完整访问路径为:http://localhost:3001/mobile.html#/button
。
2.2 路由方式优化
当前组件库工程里面已包含业务场景的工程,采用的history
路由,如果移动端预览也采用history
路由,在开发阶段会和业务场景冲突,故移动端预览采用的hash
路由。
2.3 打包优化
根据vite
多页面应用模式的配置,最初将业务场景的代码和移动端预览的代码打包到一起了,后面考虑到两个工程的代码会有分开部署的需求,故对打包配置做了调整,调整后如下:
// vite.config.ts
// 业务场景代码的配置文件,修改输出目录即可
export default defineConfig({
// 其他配置...
build: {
outDir: 'dist-src'
}
})
// 根目录新建vite.config.mobile.ts
// 移动端预览配置文件,指定打包入口和输出目录
export default defineConfig({
// 其他配置...
build: {
outDir: 'dist-mobile',
rollupOptions: {
input: {
mobile: path.resolve(__dirname, 'mobile.html')
}
}
}
})
3 父子页面路由同步
3.1 场景
工程搭建方面的配置基本完成,接下来就需要考虑具体功能实现了。
目前官网(后面用父页面
来表示)通过vitepress
能够正常访问,移动端预览(后面用子页面
来表示)通过vue3+vite
的建站方案也能够正常访问。
那两个网站的路由状态如何保持一致呢?
即需要满足以下几个场景:
- 点击父页面的菜单,子页面路由需要相应修改
- 点击子页面的菜单,父页面路由需要相应修改
- 点击浏览器前进和后退按钮,父子页面路由需要同步修改
- 点击子页面的返回按钮,父子页面路由需要同步修改
3.2 父子页面通信
由于我们采用的是iframe
的方案,所以此功能就涉及到iframe父子传参通信,通信方式分为两种:同域
和跨域
。
简而言之,同域通信就是通过访问到对应域的window
对象,然后进行相应的操作。
跨域通信就是通过postMessage
的能力进行消息同步。
同域存在以下两个问题:
- 由于本地开发,两个工程分别启动在两个端口下面,无法实现同域通信。
- 同域通过
window.location
去修改路由,会存在刷新页面的情况,而iframe
标签上的src
地址是在父页面渲染时去初始化的,如果刷新页面会再次初始化iframe
标签上的src
地址。而我们想要实现的效果是,只在父页面渲染的时候初始化一次iframe
标签上的src
地址,后续的操作,iframe
标签上的src
地址不再变化,只是内部路由变化。
故同域通信的方案被排除。
接下来就是跨域通信的方案了~
因为我们的需求是父子页面路由状态双向同步,所以在父子页面均需要发送消息和接受消息。
父页面通过watch
监听在路由变化时,发送消息,代码如下:
// 父页面发送消息
watch(
() => route.path,
(newPath) => {
// 根路由,path为'/';其他路由,为'/component/xxx/'
const pathArr = newPath?.split('/');
const oFrame = document.querySelector('iframe');
oFrame?.contentWindow?.postMessage(
{
type: 'devui',
value: newPath === '/' ? '/' : pathArr[pathArr.length - 2],
},
'*',
);
},
);
父页面接受消息代码如下:
// 父页面接受消息
window.addEventListener('message', function (event) {
if (event.data.type && event.data.type === 'devui') {
router.go(event.data.value ? `/components/${event.data.value}/` : '/');
}
});
子页面同样在路由变化时发送消息:
watch(router.currentRoute, (newPath) => {
// 路由变更来源分为两种:
// parent---由父页面影响,包括父页面路由变更,预览页面同步切换;页面首次渲染。触发watch
// ''-------点击子页面首页的菜单时,触发watch
// 值为parent的情况为父页面影响预览页面,无需再次通过postMessage通信
const routeChangeFromParent =
sessionStorage.getItem('routeChangeFromParent') === 'parent';
if (routeChangeFromParent) {
sessionStorage.setItem('routeChangeFromParent', '');
return;
}
window.parent.postMessage(
{
type: 'devui',
value: getLinkUrl(newPath.path),
},
'*',
);
});
子页面接受消息代码如下:
window.addEventListener('message', (event) => {
if (event.data.type && event.data.type === 'devui') {
sessionStorage.setItem('routeChangeFromParent', 'parent');
router.replace(event.data.value);
}
});
子页面返回上一页面的实现:
点击返回按钮,需要执行浏览器的返回操作,即history.back()
。此时父页面的watch
监听到路由变化,然后通知子页面变更路由。
3.3 遇到的问题
一、路由变更死循环
由于子页面路由变化来源有两种:父页面通知和点击子页面菜单,为了避免死循环:子通知父变更,父变更之后,再次通知子变更,子又通知父变更…,通过sessionStorage
做了标记进行判断拦截。
二、页面显示不出来
这个问题出现在页面渲染的时候。
三、选中页面文本,页面错乱
这个问题出现在选中页面文本的时候。
通过对第二三个问题的分析,发现是message
事件被“意外”触发,定位之后发现是因为某些Chrome插件(Augury和沙拉查词)触发了message
事件,所以增加了devui
的标志,对message
事件来源进行过滤。
4 子页面生成
组件库支持PC端和Mobile端组件,在编写文档的时候,演示demo写入了文档中。
增加移动端预览的功能之后,预览框中也需要演示demo,并且演示demo需要和文档中的保持一致。
这时就需要维护两份演示demo。
为了降低后期维护的工作量,就想到了通过自动化脚本
的方式,从Markdown文档中提取演示demo代码,然后再输出到移动端预览工程中。
自动化脚本需要支持单个组件和全部组件转换,输入all
表示全部转换。
整个自动化脚本大概分为以下几步:
4.1 如何获知要转换哪个组件?
通过命令行的方式,输入命令之后,提示想要转换的组件名称,组件名称需要和组件文件夹名字保持一致,用来从指定文件夹下提取文档内容。
命令交互实现如下:
// mobile-cli-index.js
// 通过commander创建命令
#!/usr/bin/env node
const { Command } = require('commander');
const { create } = require('./generate-mobile-demo');
const { version } = require('../package.json');
const program = new Command();
program
.command('create')
.description('从md提取演示demo代码,自动输出到mobile文件夹')
.action(create);
program.parse().version(version);
// mobile-inquirers.js
// 通过inquirer提供交互操作
exports.inputComponentName = () => ({
name: 'name',
type: 'input',
message:
'(必填)请输入组件名字,将该组件md中的演示代码提取到mobile中,仅支持mobile组件,all表示转换所有Mobile组件:',
validate: (value) => {
if (value.trim() === '') {
return '组件 name 是必填项';
}
return true;
},
});
// generate-mobile-demo.js
// 提供输入命令之后要执行的操作
const inquirer = require('inquirer');
const { inputComponentName } = require('./mobile-inquirers');
exports.create = async () => {
const { name } = await inquirer.prompt([inputComponentName()]);
if (!validateComponentName(name.toLocaleLowerCase())) {
console.log('该组件不支持Mobile');
process.exit(0);
}
if (name === 'all') {
// 通过遍历的方式,对所有Mobile组件进行转换
mobileComponents.forEach((value, key) => {
reset();
generateSingleComponent(value, key);
});
} else {
// 单个组件转换
generateSingleComponent(mobileComponents.get(name), name);
}
};
4.2 提取Markdown内容
通过node
的能力读取文档内容,实现如下:
const mdFileContent = fs.readFileSync(
path.resolve(__dirname, `../docs/components/${componentName}/index.md`),
'utf8',
);
4.3 如何从Markdown内容中提取想要的信息?
目前想到两种方案可以实现:
- 一种是用正则对字符串进行切割;
- 另一种方案是将Markdown文档转成
AST
结构,对AST
进行遍历,获取到想要的数据。
因AST
转换更加准确严谨,具备清晰的结构化信息,更加灵活;
而正则表达式需要考虑太多边界情况,无法有效分析上下文。
故采取AST
的方案。
借用@textlint/markdown-to-ast
插件将Markdown
文档转成AST
的结构:
const mdParser = require('@textlint/markdown-to-ast').parse;
const mdAST = mdParser(mdFileContent);
AST
的结构如下(只截图了部分):
通过对AST
遍历提取代码,约定好文档中的style
、script
、三级标题及其后面紧跟的html
或者CodeBlock
为我们需要的数据。具体提取过程如下:
mdAST.children.forEach(({ type, depth, lang, raw }) => {
if (STYLE_REG.test(raw)) {
styleStr = raw;
} else if (SCRIPT_REG.test(raw)) {
scriptStr = raw;
} else if (type === 'Header' && depth === 3) {
needPickHtml = true;
let title = raw.replace('### ', '');
isFlow
? (htmlStr += `<div class="demo-block__title">${title}</div>`)
: tabTitleArr.push(title);
} else if (isPureMobile) {
if (needPickHtml && type === 'CodeBlock' && lang && lang === 'html') {
let content = raw.replace('```html', '').replace('```', '');
isFlow ? (htmlStr += content) : tabContentArr.push(content);
needPickHtml = false;
}
} else {
if (needPickHtml && type === 'Html') {
isFlow ? (htmlStr += raw) : tabContentArr.push(raw);
needPickHtml = false;
}
}
});
- 通过
STYLE_REG
和SCRIPT_REG
识别到Markdown文档中的style
和script
代码。 - 识别三级标题,作为演示demo中的分类标题。
- 因纯
Mobile
组件,演示demo不方便直接在文档中展示,故提取type === CodeBlock
的代码。 - 因支持
PC
和Mobile
的组件,CodeBlock
中的代码在预览框中不一定能正常展示,故提取type === html
的代码。
4.4 拼接输出字符串
完成html
、style
、script
代码提取之后,接下来就是拼接字符串。
字符串的拼接有三种情况:
- 一种是Markdown文档中没有
script
代码,这种输出的代码里面需要写好script
代码; - 第二种是有
script
代码的情况,这种需要把html
、style
和script
的代码都拼接进去; - 第三种是输出的
Mobile
不是流式布局(参考预览框的Button
组件),而是Tab
结构的布局(参考预览框的PullRefresh
组件),这种需要对拼接的字符串做处理。
具体拼接不做过多介绍,下面只展示第二种情况的代码:
function createTemplate() {
return `${scriptStr}
<template>
<div class="component-content">
${htmlStr}
</div>
</template>
${styleStr}
`
}
字符串拼接完成之后,就是通过node的能力输出到文件中。这个比较简单,不做过多描述了,直接贴代码:
let fileStr = createTemplate()
fs.outputFile(path.resolve(componentDir, 'index.vue'), fileStr, 'utf8');
4.5 移动端预览首页和路由表生成
移动端预览首页同样是自动生成的,因文档左侧的菜单栏是根据docs/.vitepress/config/sidebar.ts
文件中的配置生成的,所以移动端预览首页也是读取的此文件中的配置生成的,只不过需要做个过滤,只渲染Mobile
组件。
移动端预览工程的路由表同样也是通过读取docs/.vitepress/config/sidebar.ts
文件中的配置生成的,通过拼接字符串的形式输出到mobile/route/index.ts
文件中。
5 小结
移动端预览是一个完整的网站,通过iframe
的方式嵌入到组件库官网中,然后通过postMessage
的能力实现父子页面路由同步。
预览页面通过生成AST的方式,从Markdown文档中提取演示代码自动生成,移动端预览的首页和路由表也同样根据组件库的菜单栏配置文件自动生成。
这样就只需要在Markdown文档中维护菜单栏和演示代码即可,降低维护的工作量。