Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537 Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537
端時間我的一個朋友為了快速熟悉 Vue3 開發(fā), 特意使用 electron+vue3+ts 開發(fā)了一個桌面端應(yīng)用, 并在 github 上開源了, 接下來我就帶大家一起了解一下這個項(xiàng)目, 在文章末尾我會放 github的地址, 大家如果想學(xué)習(xí)vue3 + ts + electron 開發(fā), 可以本地 clone 學(xué)習(xí)參考一下.
關(guān)注并將「趣談前端」設(shè)為星標(biāo)
每天定時分享技術(shù)干貨/優(yōu)秀開源/技術(shù)思維
image.png
以上是我們看到的便簽軟件使用界面, 整體技術(shù)選型如下:
接下來我們來看看具體的演示效果:
具體實(shí)現(xiàn)過程, 內(nèi)容很長, 建議先點(diǎn)贊收藏, 再一步步學(xué)習(xí), 接下來會就該項(xiàng)目的每一個重點(diǎn)細(xì)節(jié)做詳細(xì)的分析.
1.頁面:
2.動效:
3.數(shù)據(jù)儲存:
數(shù)據(jù)的創(chuàng)建和更新都在編輯頁editor.vue進(jìn)行,這個過程中在儲存進(jìn)nedb之后才通信列表頁index.vue更新內(nèi)容,考慮到性能問題,這里使用了防抖防止連續(xù)性的更新而導(dǎo)致卡頓(不過貌似沒有這個必要。。也算是一個小功能吧,然后可以設(shè)置這個更新速度)
4.錯誤采集:采集在使用中的錯誤并彈窗提示
5.編輯顯示:document暴露 execCommand 方法,該方法允許運(yùn)行命令來操縱可編輯內(nèi)容區(qū)域的元素。
在開發(fā)的時候還遇到過好多坑,這些都是在electron環(huán)境中才有,比如
其他的不記得了。。
這里暫時不提供vue3和electron介紹,有需要的可以先看看社區(qū)其他的有關(guān)文章或者后期再詳細(xì)專門提供。軟件命名為i-notes。
electron-vue里面的包環(huán)境太低了,所以是手動配置electron+vue3(雖然說是手動。。其實(shí)就兩個步驟)
目錄結(jié)構(gòu)
electron-vue-notes
├── public
│ ├── css
│ ├── font
│ └── index.html
├── src
│ ├── assets
│ │ └── empty-content.svg
│ ├── components
│ │ ├── message
│ │ ├── rightClick
│ │ ├── editor.vue
│ │ ├── header.vue
│ │ ├── input.vue
│ │ ├── messageBox.vue
│ │ ├── switch.vue
│ │ └── tick.vue
│ ├── config
│ │ ├── browser.options.ts
│ │ ├── classNames.options.ts
│ │ ├── editorIcons.options.ts
│ │ ├── index.ts
│ │ └── shortcuts.keys.ts
│ ├── inotedb
│ │ └── index.ts
│ ├── less
│ │ └── index.less
│ ├── router
│ │ └── index.ts
│ ├── script
│ │ └── deleteBuild.js
│ ├── store
│ │ ├── exeConfig.state.ts
│ │ └── index.ts
│ ├── utils
│ │ ├── errorLog.ts
│ │ └── index.ts
│ ├── views
│ │ ├── editor.vue
│ │ ├── index.vue
│ │ ├── main.vue
│ │ └── setting.vue
│ ├── App.vue
│ ├── background.ts
│ ├── main.ts
│ └── shims-vue.d.ts
├── .browserslistrc
├── .eslintrc.js
├── .prettierrc.js
├── babel.config.js
├── inoteError.log
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── tsconfig.json
├── vue.config.js
└── yarn.lock
沒有腳手架的可以先安裝腳手架
npm install -g @vue/cli
創(chuàng)建vue3項(xiàng)目
vue create electron-vue-notes
# 后續(xù)
? Please pick a preset: (Use arrow keys)
Default ([Vue 2] babel, eslint)
Default (Vue 3 Preview) ([Vue 3] babel, eslint)
> Manually select features
# 手動選擇配置
# 后續(xù)所有配置
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, TS, Router, CSS Pre-processors, Linter
? Choose a version of Vue.js that you want to start the project with 3.x (Preview)
? Use class-style component syntax? Yes
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Use history mode for router? (Requires proper server setup for index fallback in production) No
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Less
? Pick a linter / formatter config: Prettier
? Pick additional lint features: Lint on save, Lint and fix on commit
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? (y/N) n
創(chuàng)建完之后的目錄是這樣的
electron-vue-notes
├── public
│ ├── favicon.ico
│ └── index.html
├── src
│ ├── assets
│ │ └── logo.png
│ ├── components
│ │ └── HelloWorld.vue
│ ├── router
│ │ └── index.ts
│ ├── views
│ │ ├── About.vue
│ │ └── Home.vue
│ ├── App.vue
│ ├── main.ts
│ └── shims-vue.d.ts
├── .browserslistrc
├── .eslintrc.js
├── babel.config.js
├── package.json
├── README.md
├── tsconfig.json
└── yarn.lock
# yarn
yarn add vue-cli-plugin-electron-builder electron
# npm 或 cnpm
npm i vue-cli-plugin-electron-builder electron
安裝完之后完善一些配置,比如別名、eslint、prettier等等基礎(chǔ)配置,還有一些顏色、icons等等具體可以看下面
使用eslint主要是規(guī)范代碼風(fēng)格,不推薦tslint是因?yàn)閠slint已經(jīng)不更新了,tslint也推薦使用eslint 安裝eslint
npm i eslint -g
進(jìn)入項(xiàng)目之后初始化eslint
eslint --init
# 后續(xù)配置
? How would you like to use ESLint? To check syntax and find problems
? What type of modules does your project use? JavaScript modules (import/export)
? Which framework does your project use? Vue.js
? Does your project use TypeScript? Yes
? Where does your code run? Browser, Node
? What format do you want your config file to be in? JavaScript
The config that you've selected requires the following dependencies:
eslint-plugin-vue@latest @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest
? Would you like to install them now with npm? (Y/n) y
修改eslint配置,·.eslintrc.js,規(guī)則rules可以根據(jù)自己的喜歡配置 eslint.org/docs/user-g…[4]
module.exports = {
root: true,
env: {
node: true
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'plugin:prettier/recommended',
'plugin:@typescript-eslint/eslint-recommended',
'@vue/typescript/recommended',
'@vue/prettier',
'@vue/prettier/@typescript-eslint'
],
parserOptions: {
ecmaVersion: 2020
},
rules: {
quotes: [1, 'single'],
semi: 1,
'@typescript-eslint/camelcase': 0,
'@typescript-eslint/no-explicit-any': 0,
'no-irregular-whitespace': 2,
'no-case-declarations': 0,
'no-undef': 0,
'eol-last': 1,
'block-scoped-var': 2,
'comma-dangle': [2, 'never'],
'no-dupe-keys': 2,
'no-empty': 1,
'no-extra-semi': 2,
'no-multiple-empty-lines': [1, { max: 1, maxEOF: 1 }],
'no-trailing-spaces': 1,
'semi-spacing': [2, { before: false, after: true }],
'no-unreachable': 1,
'space-infix-ops': 1,
'spaced-comment': 1,
'no-var': 2,
'no-multi-spaces': 2,
'comma-spacing': 1
}
};
在根目錄增加.prettierrc.js配置,根據(jù)自己的喜好進(jìn)行配置,單行多少個字符、單引號、分號、逗號結(jié)尾等等
module.exports = {
printWidth: 120,
singleQuote: true,
semi: true,
trailingComma: 'none'
};
如果這里沒有配置識別@/路徑的話,在項(xiàng)目中使用會報(bào)錯
"paths": {
"@/*": [
"src/*"
]
}
"author": "heiyehk",
"description": "I便箋個人開發(fā)者h(yuǎn)eiyehk獨(dú)立開發(fā),在Windows中更方便的記錄文字。",
"main": "background.js",
"scripts": {
"lint": "vue-cli-service lint",
"electron:build": "vue-cli-service electron:build",
"electron:serve": "vue-cli-service electron:serve"
}
因?yàn)樾枰鲆恍┐蜷_和關(guān)閉的動效,因此我們需要配置electron為frame無邊框和透明transparent的屬性
/* eslint-disable @typescript-eslint/no-empty-function */
'use strict';
import { app, protocol, BrowserWindow, globalShortcut } from 'electron';
import {
createProtocol
// installVueDevtools
} from 'vue-cli-plugin-electron-builder/lib';
const isDevelopment = process.env.NODE_ENV !== 'production';
let win: BrowserWindow | null;
protocol.registerSchemesAsPrivileged([
{
scheme: 'app',
privileges: {
secure: true,
standard: true
}
}
]);
function createWindow() {
win = new BrowserWindow({
frame: false, // 無邊框
hasShadow: false,
transparent: true, // 透明
width: 950,
height: 600,
webPreferences: {
enableRemoteModule: true,
nodeIntegration: true
}
});
if (process.env.WEBPACK_DEV_SERVER_URL) {
win.loadURL(process.env.WEBPACK_DEV_SERVER_URL);
if (!process.env.IS_TEST) win.webContents.openDevTools();
} else {
createProtocol('app');
win.loadURL('http://localhost:8080');
}
win.on('closed', () => {
win = null;
});
}
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (win === null) {
createWindow();
}
});
app.on('ready', async () => {
// 這里注釋掉是因?yàn)闀惭btools插件,需要屏蔽掉,有能力的話可以打開注釋
// if (isDevelopment && !process.env.IS_TEST) {
// try {
// await installVueDevtools();
// } catch (e) {
// console.error('Vue Devtools failed to install:', e.toString());
// }
// }
createWindow();
});
if (isDevelopment) {
if (process.platform === 'win32') {
process.on('message', data => {
if (data === 'graceful-exit') {
app.quit();
}
});
} else {
process.on('SIGTERM', () => {
app.quit();
});
}
}
yarn electron:serve
到這里配置就算是成功搭建好這個窗口了,但是還有一些其他細(xì)節(jié)需要進(jìn)行配置,比如electron打包配置,模塊化的配置等等
這里配置一些常用的開發(fā)內(nèi)容和一些輪子代碼, 大家可以參考 reset.csss 和 common.css 這兩個文件.
這個對應(yīng)項(xiàng)目中的config文件夾
config
├── browser.options.ts # 窗口的配置
├── classNames.options.ts # 樣式名的配置,背景樣式都通過這個文件渲染
├── editorIcons.options.ts # 編輯頁面的一些editor圖標(biāo)
├── index.ts # 導(dǎo)出
└── shortcuts.keys.ts # 禁用的一些快捷鍵,electron是基于chromium瀏覽器,所以也存在一些瀏覽器快捷鍵比如F5
這個文件的主要作用就是配置主窗口和編輯窗口區(qū)分開發(fā)正式的配置,寬高等等,以及要顯示的主頁面
/**
* 軟件數(shù)據(jù)和配置
* C:\Users\{用戶名}\AppData\Roaming
* 共享
* C:\ProgramData\Intel\ShaderCache\i-notes{xx}
* 快捷方式
* C:\Users\{用戶名}\AppData\Roaming\Microsoft\Windows\Recent
* 電腦自動創(chuàng)建緩存
* C:\Windows\Prefetch\I-NOTES.EXE{xx}
*/
/** */
const globalEnv = process.env.NODE_ENV;
const devWid = globalEnv === 'development' ? 950 : 0;
const devHei = globalEnv === 'development' ? 600 : 0;
// 底部icon: 40*40
const editorWindowOptions = {
width: devWid || 290,
height: devHei || 350,
minWidth: 250
};
/**
* BrowserWindow的配置項(xiàng)
* @param type 單獨(dú)給編輯窗口的配置
*/
const browserWindowOption = (type?: 'editor'): Electron.BrowserWindowConstructorOptions => {
const commonOptions = {
minHeight: 48,
frame: false,
hasShadow: true,
transparent: true,
webPreferences: {
enableRemoteModule: true,
nodeIntegration: true
}
};
if (!type) {
return {
width: devWid || 350,
height: devHei || 600,
minWidth: 320,
...commonOptions
};
}
return {
...editorWindowOptions,
...commonOptions
};
};
/**
* 開發(fā)環(huán)境: http://localhost:8080
* 正式環(huán)境: file://${__dirname}/index.html
*/
const winURL = globalEnv === 'development' ? 'http://localhost:8080' : `file://${__dirname}/index.html`;
export { browserWindowOption, winURL };
增加meta中的title屬性,顯示在軟件上方頭部
import { createRouter, createWebHashHistory } from 'vue-router';
import { RouteRecordRaw } from 'vue-router';
import main from '../views/main.vue';
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'main',
component: main,
children: [
{
path: '/',
name: 'index',
component: () => import('../views/index.vue'),
meta: {
title: 'I便箋'
}
},
{
path: '/editor',
name: 'editor',
component: () => import('../views/editor.vue'),
meta: {
title: ''
}
},
{
path: '/setting',
name: 'setting',
component: () => import('../views/setting.vue'),
meta: {
title: '設(shè)置'
}
}
]
}
];
const router = createRouter({
history: createWebHashHistory(process.env.BASE_URL),
routes
});
export default router;
main.vue文件主要是作為一個整體框架,考慮到頁面切換時候的動效,分為頭部和主體部分,頭部作為一個單獨(dú)的組件處理,內(nèi)容區(qū)域使用router-view渲染。html部分,這里和vue2.x有點(diǎn)區(qū)別的是,在vue2.x中可以直接
// bad
<transition name="fade">
<keep-alive>
<router-view />
</keep-alive>
</transition>
上面的這種寫法在vue3中會在控制臺報(bào)異常,記不住寫法的可以看看控制臺
<router-view v-slot="{ Component }">
<transition name="main-fade">
<div class="transition" :key="routeName">
<keep-alive>
<component :is="Component" />
</keep-alive>
</div>
</transition>
</router-view>
然后就是ts部分了,使用vue3的寫法去寫,script標(biāo)簽注意需要寫上lang="ts"代表是ts語法。router的寫法也不一樣,雖然在vue3中還能寫vue2的格式,但是不推薦使用。這里是獲取route的name屬性,來進(jìn)行一個頁面過渡的效果。
<script lang="ts">
import { defineComponent, ref, onBeforeUpdate } from 'vue';
import { useRoute } from 'vue-router';
import Header from '@/components/header.vue';
export default defineComponent({
components: {
Header
},
setup() {
const routeName = ref(useRoute().name);
onBeforeUpdate(() => {
routeName.value = useRoute().name;
});
return {
routeName
};
}
});
</script>
less部分
<style lang="less" scoped>
.main-fade-enter,
.main-fade-leave-to {
display: none;
opacity: 0;
animation: main-fade 0.4s reverse;
}
.main-fade-enter-active,
.main-fade-leave-active {
opacity: 0;
animation: main-fade 0.4s;
}
@keyframes main-fade {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
以上就是main.vue的內(nèi)容,在頁面刷新或者進(jìn)入的時候根據(jù)useRouter().name的切換進(jìn)行放大的過渡效果,后面的內(nèi)容會更簡潔一點(diǎn)。
頭部組件還有一個標(biāo)題過渡的效果,根據(jù)路由導(dǎo)航獲取當(dāng)前路由的mate.title變化進(jìn)行過渡效果。vue3中路由守衛(wèi)需要從vue-route導(dǎo)入使用。
import { onBeforeRouteUpdate, useRoute } from 'vue-router';
...
onBeforeRouteUpdate((to, from, next) => {
title.value = to.meta.title;
currentRouteName.value = to.name;
next();
});
這里是計(jì)算不同的路由下標(biāo)題內(nèi)邊距的不同,首頁是有個設(shè)置入口的按鈕,而設(shè)置頁面是只有兩個按鈕,computed會返回一個你需要的新的值
// 獲取首頁的內(nèi)邊距
const computedPaddingLeft = computed(() => {
return currentRouteName.value === 'index' ? 'padding-left: 40px;' : '';
});
vue3沒有了this,那么要使用emit怎么辦呢?在入口setup中有2個參數(shù)
setup(props, content) {}
props是父組件傳給子組件的內(nèi)容,props常用的emit和props都在content中。
這里需要注意的是,使用props和emit需要先定義,才能去使用,并且會在vscode中直接調(diào)用時輔助彈窗顯示
props示例
emit示例
export default defineComponent({
props: {
test: String
},
emits: ['option-click', 'on-close'],
// 如果只用emit的話可以使用es6解構(gòu)
// 如:setup(props, { emit })
setup(props, content) {
console.log(props.test, content.emit('option-click'));
}
})
import { browserWindowOption } from '@/config';
import { createBrowserWindow, transitCloseWindow } from '@/utils';
...
const editorWinOptions = browserWindowOption('editor');
// 打開新窗口
const openNewWindow = () => {
createBrowserWindow(editorWinOptions, '/editor');
};
先獲取當(dāng)前屏幕實(shí)例
這里需要注意的是,需要從remote獲取當(dāng)前窗口信息
判斷當(dāng)前窗口是否在最前面isAlwaysOnTop(),然后通過setAlwaysOnTop()屬性設(shè)置當(dāng)前窗口最前面。
import { remote } from 'electron';
...
// 獲取窗口固定狀態(tài)
let isAlwaysOnTop = ref(false);
const currentWindow = remote.getCurrentWindow();
isAlwaysOnTop.value = currentWindow.isAlwaysOnTop();
// 固定前面
const drawingPin = () => {
if (isAlwaysOnTop.value) {
currentWindow.setAlwaysOnTop(false);
isAlwaysOnTop.value = false;
} else {
currentWindow.setAlwaysOnTop(true);
isAlwaysOnTop.value = true;
}
};
這里是在utils封裝了通過對dom的樣式名操作,達(dá)到一個退出的過渡效果,然后再關(guān)閉。
// 過渡關(guān)閉窗口
export const transitCloseWindow = (): void => {
document.querySelector('#app')?.classList.remove('app-show');
document.querySelector('#app')?.classList.add('app-hide');
remote.getCurrentWindow().close();
};
安裝nedb數(shù)據(jù)庫,文檔: www.w3cschool.cn/nedbintro/n…[5]
yarn add nedb @types/nedb
數(shù)據(jù)儲存在nedb中,定義字段,并在根目錄的shims-vue.d.ts加入類型
/**
* 儲存數(shù)據(jù)庫的
*/
interface DBNotes {
className: string; // 樣式名
content: string; // 內(nèi)容
readonly createdAt: Date; // 創(chuàng)建時間,這個時間是nedb自動生成的
readonly uid: string; // uid,utils中的方法生成
readonly updatedAt: Date; // update,自動創(chuàng)建的
readonly _id: string; // 自動創(chuàng)建的
}
對nedb的封裝
這里的QueryDB是shims-vue.d.ts定義好的類型
這里的意思是QueryDB<T>是一個對象,然后這個對象傳入一個泛型T,這里keyof T獲取這個對象的key(屬性)值,?:代表這個key可以是undefined,表示可以不存在。T[K]表示從這個對象中獲取這個K的值。
type QueryDB<T> = {
[K in keyof T]?: T[K];
};
import Datastore from 'nedb';
import path from 'path';
import { remote } from 'electron';
/**
* @see https://www.npmjs.com/package/nedb
*/
class INoteDB<G = any> {
/**
* 默認(rèn)儲存位置
* C:\Users\{Windows User Name}\AppData\Roaming\i-notes
*/
// dbPath = path.join(remote.app.getPath('userData'), 'db/inote.db');
// dbPath = './db/inote.db';
dbPath = this.path;
_db: Datastore<Datastore.DataStoreOptions> = this.backDatastore;
get path() {
if (process.env.NODE_ENV === 'development') {
return path.join(__dirname, 'db/inote.db');
}
return path.join(remote.app.getPath('userData'), 'db/inote.db');
}
get backDatastore() {
return new Datastore({
/**
* autoload
* default: false
* 當(dāng)數(shù)據(jù)存儲被創(chuàng)建時,數(shù)據(jù)將自動從文件中加載到內(nèi)存,不必去調(diào)用loadDatabase
* 注意所有命令操作只有在數(shù)據(jù)加載完成后才會被執(zhí)行
*/
autoload: true,
filename: this.dbPath,
timestampData: true
});
}
refreshDB() {
this._db = this.backDatastore;
}
insert<T extends G>(doc: T) {
return new Promise((resolve: (value: T) => void) => {
this._db.insert(doc, (error: Error | null, document: T) => {
if (!error) resolve(document);
});
});
}
/**
* db.find(query)
* @param {Query<T>} query:object類型,查詢條件,可以使用空對象{}。
* 支持使用比較運(yùn)算符($lt, $lte, $gt, $gte, $in, $nin, $ne)
* 邏輯運(yùn)算符($or, $and, $not, $where)
* 正則表達(dá)式進(jìn)行查詢。
*/
find(query: QueryDB<DBNotes>) {
return new Promise((resolve: (value: DBNotes[]) => void) => {
this._db.find(query, (error: Error | null, document: DBNotes[]) => {
if (!error) resolve(document as DBNotes[]);
});
});
}
/**
* db.findOne(query)
* @param query
*/
findOne(query: QueryDB<DBNotes>) {
return new Promise((resolve: (value: DBNotes) => void) => {
this._db.findOne(query, (error: Error | null, document) => {
if (!error) resolve(document as DBNotes);
});
});
}
/**
* db.remove(query, options)
* @param {Record<keyof DBNotes, any>} query
* @param {Nedb.RemoveOptions} options
* @return {BackPromise<number>}
*/
remove(query: QueryDB<DBNotes>, options?: Nedb.RemoveOptions) {
return new Promise((resolve: (value: number) => void) => {
if (options) {
this._db.remove(query, options, (error: Error | null, n: number) => {
if (!error) resolve(n);
});
} else {
this._db.remove(query, (error: Error | null, n: number) => {
if (!error) resolve(n);
});
}
});
}
update<T extends G>(query: T, updateQuery: T, options: Nedb.UpdateOptions = {}) {
return new Promise((resolve: (value: T) => void) => {
this._db.update(
query,
updateQuery,
options,
(error: Error | null, numberOfUpdated: number, affectedDocuments: T) => {
if (!error) resolve(affectedDocuments);
}
);
});
}
}
export default new INoteDB();
用ref和reactive引入的方式就可以達(dá)到vuex的state效果,這樣就可以完全舍棄掉vuex。比如軟件配置,創(chuàng)建exeConfig.state.ts在store中,這樣在外部.vue文件中進(jìn)行更改也能去更新視圖。
import { reactive, watch } from 'vue';
const exeConfigLocal = localStorage.getItem('exeConfig');
export let exeConfig = reactive({
syncDelay: 1000,
...
switchStatus: {
/**
* 開啟提示
*/
textTip: true
}
});
if (exeConfigLocal) {
exeConfig = reactive(JSON.parse(exeConfigLocal));
} else {
localStorage.setItem('exeConfig', JSON.stringify(exeConfig));
}
watch(exeConfig, e => {
localStorage.setItem('exeConfig', JSON.stringify(e));
});
vuex的使用是直接在項(xiàng)目中引入useStore,但是是沒有state類型提示的,所以需要手動去推導(dǎo)state的內(nèi)容。這里的S代表state的類型,然后傳入vuex中export declare class Store<S> { readonly state: S; }
想要查看某個值的類型的時候在vscode中ctrl+鼠標(biāo)左鍵點(diǎn)進(jìn)去就能看到,或者鼠標(biāo)懸浮該值
declare module 'vuex' {
type StoreStateType = typeof store.state;
export function useStore<S = StoreStateType>(): Store<S>;
}
vue3也發(fā)布了有段時間了,雖然還沒有完全穩(wěn)定,但后面的時間出現(xiàn)的插件開發(fā)方式說不定也會多起來。插件開發(fā)思路
import { createApp, h, App, VNode, RendererElement, RendererNode } from 'vue';
import './index.css';
type ClassName = string | string[];
interface MenuOptions {
/**
* 文本
*/
text: string;
/**
* 是否在使用后就關(guān)閉
*/
once?: boolean;
/**
* 單獨(dú)的樣式名
*/
className?: ClassName;
/**
* 圖標(biāo)樣式名
*/
iconName?: ClassName;
/**
* 函數(shù)
*/
handler(): void;
}
type RenderVNode = VNode<
RendererNode,
RendererElement,
{
[key: string]: any;
}
>;
class CreateRightClick {
rightClickEl?: App<Element>;
rightClickElBox?: HTMLDivElement | null;
constructor() {
this.removeRightClickHandler();
}
/**
* 渲染dom
* @param menu
*/
render(menu: MenuOptions[]): RenderVNode {
return h(
'ul',
{
class: ['right-click-menu-list']
},
[
...menu.map(item => {
return h(
'li',
{
class: item.className,
// vue3.x中簡化了render,直接onclick即可,onClick也可以
onclick: () => {
// 如果只是一次,那么點(diǎn)擊之后直接關(guān)閉
if (item.once) this.remove();
return item.handler();
}
},
[
// icon
h('i', {
class: item.iconName
}),
// text
h(
'span',
{
class: 'right-click-menu-text'
},
item.text
)
]
);
})
]
);
}
/**
* 給右鍵的樣式
* @param event 鼠標(biāo)事件
*/
setRightClickElStyle(event: MouseEvent, len: number): void {
if (!this.rightClickElBox) return;
this.rightClickElBox.style.height = `${len * 36}px`;
const { clientX, clientY } = event;
const { innerWidth, innerHeight } = window;
const { clientWidth, clientHeight } = this.rightClickElBox;
let cssText = `height: ${len * 36}px;opacity: 1;transition: all 0.2s;`;
if (clientX + clientWidth < innerWidth) {
cssText += `left: ${clientX + 2}px;`;
} else {
cssText += `left: ${clientX - clientWidth}px;`;
}
if (clientY + clientHeight < innerHeight) {
cssText += `top: ${clientY + 2}px;`;
} else {
cssText += `top: ${clientY - clientHeight}px;`;
}
cssText += `height: ${len * 36}px`;
this.rightClickElBox.style.cssText = cssText;
}
remove(): void {
if (this.rightClickElBox) {
this.rightClickElBox.remove();
this.rightClickElBox = null;
}
}
removeRightClickHandler(): void {
document.addEventListener('click', e => {
if (this.rightClickElBox) {
const currentEl = e.target as Node;
if (!currentEl || !this.rightClickElBox.contains(currentEl)) {
this.remove();
}
}
});
}
/**
* 鼠標(biāo)右鍵懸浮
* @param event
* @param menu
*/
useRightClick = (event: MouseEvent, menu: MenuOptions[] = []): void => {
this.remove();
if (!this.rightClickElBox || !this.rightClickEl) {
const createRender = this.render(menu);
this.rightClickEl = createApp({
setup() {
return () => createRender;
}
});
}
if (!this.rightClickElBox) {
this.rightClickElBox = document.createElement('div');
this.rightClickElBox.id = 'rightClick';
document.body.appendChild(this.rightClickElBox);
this.rightClickEl.mount('#rightClick');
}
this.setRightClickElStyle(event, menu.length);
};
}
export default CreateRightClick;
在使用的時候直接引入即可,如在index.vue中使用創(chuàng)建右鍵的方式,這里需要額外的說明一下,打開窗口需要進(jìn)行一個窗口通信判斷,ipcMain需要從remote中獲取
on是一直處于通信狀態(tài),once是通信一次之后就關(guān)閉了
// countFlag是一個狀態(tài)來標(biāo)記收到東西沒
// index問editor打開了沒有
ipcRenderer.send('你好')
// 這時候editor收到消息了
remote.ipcMain.on('你好', e => {
// 收到消息后顯示
remote.getCurrentWindow().show();
// 然后回index消息
e.sender.send('你好我在的');
});
// index在等editor消息
ipcRenderer.on('你好我在的', () => {
// 好的我收到了
countFlag = true;
});
// 如果沒收到消息,那標(biāo)記一直是false,根據(jù)定時器來做相應(yīng)操作
這里的打開筆記功能會把選中的筆記uid當(dāng)作一個query參數(shù)跳轉(zhuǎn)到編輯頁
import CreateRightClick from '@/components/rightClick';
...
const rightClick = new CreateRightClick();
...
const contextMenu = (event: MouseEvent, uid: string) => {
rightClick.useRightClick(event, [
{
text: '打開筆記',
once: true,
iconName: ['iconfont', 'icon-newopen'],
handler: () => {
let countFlag = false;
ipcRenderer.send(`${uid}_toOpen`);
ipcRenderer.on(`get_${uid}_toOpen`, () => {
countFlag = true;
});
setTimeout(() => {
if (!countFlag) openEditorWindow(uid);
}, 100);
}
},
{
text: '刪除筆記',
once: true,
iconName: ['iconfont', 'icon-delete'],
handler: () => {
deleteCurrentUid.value = uid;
if (exeConfig.switchStatus.deleteTip) {
deleteMessageShow.value = true;
} else {
// 根據(jù)彈窗組件進(jìn)行判斷
onConfirm();
}
}
}
]);
};
...
這個editor.vue是view/文件夾下的,以下對本頁面統(tǒng)稱編輯頁,更好區(qū)分editor組件和頁面 開發(fā)思路
富文本編輯做成了一個單獨(dú)的組件,使編輯頁的代碼不會太臃腫
document.execCommand文檔 developer.mozilla.org/zh-CN/docs/…[6]
首先在編輯頁對路由進(jìn)行判斷是否存在,如果不存在就創(chuàng)建,否則就查詢并把查詢到的筆記傳給editor組件
<Editor :content="editContent" :className="currentBgClassName" @on-input="changeEditContent" />
const routeUid = useRoute().query.uid as string;
if (routeUid) {
// 查詢
uid.value = routeUid;
getCurUidItem(routeUid);
} else {
// 生成uid并把uid放到地址欄
const uuidString = uuid();
uid.value = uuidString;
useRouter().push({
query: {
uid: uuidString
}
});
// 插入數(shù)據(jù)庫并向列表頁通信
...
}
原理是通過getSelection選擇光標(biāo)和createRange文本范圍兩個方法,選中富文本節(jié)點(diǎn)。獲取
import { defineComponent, onMounted, ref, Ref, watch } from 'vue';
...
// setup中創(chuàng)建一個和<div ref="editor">同名的變量,就可以直接拿到dom節(jié)點(diǎn),一定要return!!!
let editor: Ref<HTMLDivElement | null> = ref(null);
onMounted(() => {
focus();
});
const focus = () => {
const range = document.createRange();
range.selectNodeContents(editor.value as HTMLDivElement);
range.collapse(false);
const selecton = window.getSelection() as Selection;
selecton.removeAllRanges();
selecton.addRange(range);
};
...
return {
editor,
...
}
這里需要注意的是因?yàn)樵诟附M件傳給子組件,然后子組件進(jìn)行更新一次會導(dǎo)致富文本無法撤回,相當(dāng)于重新給富文本組件賦值渲染了一次,因此這里就只用一次props.content
export default defineComponent({
props: {
content: String,
className: String
},
emits: ['on-input'],
setup(props, { emit }) {
let editor: Ref<HTMLDivElement | null> = ref(null);
const bottomIcons = editorIcons;
const editorContent: Ref<string | undefined> = ref('');
// 監(jiān)聽從父組件傳來的內(nèi)容,因?yàn)槭菑臄?shù)據(jù)庫查詢所以會有一定的延遲
watch(props, nv => {
if (!editorContent.value) {
// 只賦值一次
editorContent.value = nv.content;
}
});
}
});
exeConfig.syncDelay是設(shè)置里面的一個時間,可以動態(tài)根據(jù)這個時間來調(diào)節(jié)儲存進(jìn)數(shù)據(jù)庫和列表的更新,獲取富文本組件的html然后儲存到數(shù)據(jù)庫并傳到列表頁更新
const changeEditorContent = debounce((e: InputEvent) => {
const editorHtml = (e.target as Element).innerHTML;
emit('on-input', editorHtml);
}, exeConfig.syncDelay);
vue自帶的粘貼事件,@paste獲取到剪切板的內(nèi)容,然后獲取文本格式的內(nèi)容e.clipboardData?.getData('text/plain')并插入富文本
const paste = (e: ClipboardEvent) => {
const pasteText = e.clipboardData?.getData('text/plain');
console.log(pasteText);
document.execCommand('insertText', false, pasteText);
};
官方和網(wǎng)上的例子是這樣:
<div ref="editor"></div>
setup(props, { emit }) {
let editor = ref(null);
return { editor }
})
直接獲取dom節(jié)點(diǎn),但其實(shí)不管這個editor是什么,只要從setup中return,就會直接標(biāo)記instance變量名,強(qiáng)行把內(nèi)容替換成dom節(jié)點(diǎn),甚至不用定義可以看看下面例子
<div ref="test"></div>
import { defineComponent, getCurrentInstance, onMounted } from 'vue';
...
setup(props, { emit }) {
onMounted(() => {
console.log(getCurrentInstance().refs);
// 得到的是test dom以及其他定義的節(jié)點(diǎn)
});
return {
test: ''
}
})
但是為了規(guī)范還是使用下面這樣
<div ref="dom"></div>
const dom = ref(null);
return {
dom
};
這里的話需要用到exeConfig.state.ts的配置信息,包括封裝的input、switch、tick組件
在這里說明一下,自動縮小、靠邊隱藏和同步設(shè)置暫時還沒有開發(fā)的
根據(jù)是否開啟提示的設(shè)置寫的一個方便控制的功能,這個功能是首先獲取初始化的節(jié)點(diǎn)高度,放置在dom的自定義數(shù)據(jù)上面data-xx,然后下次顯示的時候再重新獲取賦值css顯示,當(dāng)然這里也是用了一個過渡效果
使用方法
<div v-tip="switch"></div>
export default defineComponent({
components: {
Tick,
Input,
Switch
},
directives: {
tip(el, { value }) {
const { height } = el.dataset;
// 儲存最初的高度
if (!height && height !== '0') {
el.dataset.height = el.clientHeight;
}
const clientHeight = height || el.clientHeight;
let cssText = 'transition: all 0.4s;';
if (value) {
cssText += `height: ${clientHeight}px;opacity: 1;`;
} else {
cssText += 'height: 0;opacity: 0;overflow: hidden;';
}
el.style.cssText = cssText;
}
}
})
原理是先隱藏一個input標(biāo)簽,然后點(diǎn)擊的之后選擇它的內(nèi)容,在使用document.execCommand('copy')復(fù)制就可以
<a @click="copyEmail">復(fù)制</a>
<input class="hide-input" ref="mailInput" type="text" value="heiyehk@foxmail.com" />
const mailInput: Ref<HTMLInputElement | null> = ref(null);
const copyEmail = () => {
if (copyStatus.value) return;
copyStatus.value = true;
mailInput.value?.select();
document.execCommand('copy');
};
return {
copyEmail
...
}
打開文件夾使用shell這個方法
import { remote } from 'electron';
remote.shell.showItemInFolder('D:');
打開默認(rèn)瀏覽器鏈接
import { remote } from 'electron';
remote.shell.openExternal('www.github.com');
收集一些使用中的錯誤,并使用message插件進(jìn)行彈窗提示,軟件寬高和屏幕寬高只是輔助信息。碰到這些錯誤之后,在軟件安裝位置輸出一個inoteError.log的錯誤日志文件,然后在設(shè)置中判斷文件是否存在,存在就打開目錄選中。
比如這個框中的才是主要的信息
main.ts我們需要進(jìn)行一下改造,并使用errorHandler進(jìn)行全局的錯誤監(jiān)控
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import outputErrorLog from '@/utils/errorLog';
const app = createApp(App);
// 錯誤收集方法
app.config.errorHandler = outputErrorLog;
app.use(router).mount('#app');
remote.app.getPath('exe')獲取軟件安裝路徑,包含軟件名.exe
export const errorLogPath = path.join(remote.app.getPath('exe'), '../inoteError.log');
flag: a代表末尾追加,確保每一行一個錯誤加上換行符'\n'
fs.writeFileSync(errorLogPath, JSON.stringify(errorLog) + '\n', { flag: 'a' });
errorLog.ts的封裝,對Error類型的封裝
import { ComponentPublicInstance } from 'vue';
import dayjs from 'dayjs';
import fs from 'fs-extra';
import os from 'os';
import { remote } from 'electron';
import path from 'path';
import useMessage from '@/components/message';
function getShortStack(stack?: string): string {
const splitStack = stack?.split('\n ');
if (!splitStack) return '';
const newStack: string[] = [];
for (const line of splitStack) {
// 其他信息
if (line.includes('bundler')) continue;
// 只保留錯誤文件信息
if (line.includes('?!.')) {
newStack.push(line.replace(/webpack-internal:\/\/\/\.\/node_modules\/.+\?!/, ''));
} else {
newStack.push(line);
}
}
// 轉(zhuǎn)換string
return newStack.join('\n ');
}
export const errorLogPath = path.join(remote.app.getPath('exe'), '../inoteError.log');
export default function(error: unknown, vm: ComponentPublicInstance | null, info: string): void {
const { message, stack } = error as Error;
const { electron, chrome, node, v8 } = process.versions;
const { outerWidth, outerHeight, innerWidth, innerHeight } = window;
const { width, height } = window.screen;
// 報(bào)錯信息
const errorInfo = {
errorInfo: info,
errorMessage: message,
errorStack: getShortStack(stack)
};
// electron
const electronInfo = { electron, chrome, node, v8 };
// 瀏覽器窗口信息
const browserInfo = { outerWidth, outerHeight, innerWidth, innerHeight };
const errorLog = {
versions: remote.app.getVersion(),
date: dayjs().format('YYYY-MM-DD HH:mm'),
error: errorInfo,
electron: electronInfo,
window: {
type: os.type(),
platform: os.platform()
},
browser: browserInfo,
screen: { width, height }
};
useMessage('程序出現(xiàn)異常', 'error');
if (process.env.NODE_ENV === 'production') {
fs.writeFileSync(errorLogPath, JSON.stringify(errorLog) + '\n', { flag: 'a' });
} else {
console.log(error);
console.log(errorInfo.errorStack);
}
}
使用此方法后封裝的結(jié)果是這樣的,message插件具體看component
這個是之前的錯誤日志文件
const appInfo = process.versions;
這個倒是沒什么好講的了,主要還是在vue.config.js文件中進(jìn)行配置一下,然后使用命令yarn electron:build即可,當(dāng)然了,還有一個打包前清空的舊的打包文件夾的腳本
打包清空dist_electron舊的打包內(nèi)容,因?yàn)?span style="color: #1E6BB8; --tt-darkmode-color: #1E6BB8;">eslint的原因,這里就用eslint-disable關(guān)掉了幾個
原理就是先獲取vue.config.js中的打包配置,如果重新配置了路徑directories.output就動態(tài)去清空
const rm = require('rimraf');
const path = require('path');
const pluginOptions = require('../../vue.config').pluginOptions;
let directories = pluginOptions.electronBuilder.builderOptions.directories;
let buildPath = '';
if (directories && directories.output) {
buildPath = directories.output;
}
// 刪除作用只用于刪除打包前的buildPath || dist_electron
// dist_electron是默認(rèn)打包文件夾
rm(path.join(__dirname, `../../${buildPath || 'dist_electron'}`), () => {});
以上就是本篇主要開發(fā)內(nèi)容了,歡迎支持我的開源項(xiàng)目electron-vue3-inote。
相關(guān)資料
github地址: https://github.com/heiyehk/electron-vue3-inote
?? 看完三件事
如果你覺得這篇內(nèi)容對你挺有啟發(fā),歡迎點(diǎn)贊+關(guān)注, 估計(jì)作者持續(xù)創(chuàng)作更高質(zhì)量的內(nèi)容.
記
—— 執(zhí)劍天涯,從你的點(diǎn)滴積累開始,所及之處,必精益求精。
在實(shí)際業(yè)務(wù)開發(fā)中,時常會有這種一段Html格式的標(biāo)簽,看下圖的情況 :
在 Flutter 中,有點(diǎn)發(fā)愁,因?yàn)?Flutter 提供的 Text 與 RichText 還解析不了這種格式的,但是你也不能使用 WebView 插件,如果使用了,你會在每一個Item中嵌入一個瀏覽器內(nèi)核,再強(qiáng)的手機(jī),也會卡,當(dāng)然肯定不能這樣做,因?yàn)檫@樣就是錯誤的做法。
小編經(jīng)過大量的嘗試與思考,終于寫出來了一個插件可以來解析了,現(xiàn)分享給大家。
小編依舊,來個pub方式:
dependencies:
flutter_html_rich_text: ^1.0.0
核心方法如下:
///htmlText 就是你的 HTML 片段了
HtmlRichText(
htmlText: txt,
),
如下代碼清單 1-3-1 就是上述圖中的效果:
/// 代碼清單 1-3-1
class TestHtmlPage extends StatefulWidget {
@override
_TestPageState createState() => _TestPageState();
}
class _TestPageState extends State<TestHtmlPage> {
String txt =
"<p>長途輪 <h4>高速驅(qū)動</h4><span style='background-color:#ff3333'>"
"<span style='color:#ffffff;padding:10px'> 3條立減 購胎抽獎</span></span></p>"
"<p>長途高速驅(qū)動輪<span ><span style='color:#cc00ff;'> 3條立減 購胎抽獎</span></span></p>";
@override
Widget build(BuildContext context) {
return Scaffold(
///一個標(biāo)題
appBar: AppBar(title: Text('A頁面'),),
body: Center(
///一個列表
child: ListView.builder(
itemBuilder: (BuildContext context, int postiont) {
return buildItemWidget(postiont);
},
itemCount: 100,
),
),
);
}
///ListView的條目
Widget buildItemWidget(int postiont) {
return Container(
///內(nèi)容邊距
padding: EdgeInsets.all(8),
child: Column(
///子Widget左對齊
crossAxisAlignment: CrossAxisAlignment.start,
///內(nèi)容包裹
mainAxisSize: MainAxisSize.min,
children: [
Text(
"測試標(biāo)題 $postiont",
style: TextStyle(fontWeight: FontWeight.w500),
),
///html富文本標(biāo)簽
Container(
margin: EdgeInsets.only(top: 8),
child: HtmlRichText(
htmlText: txt,
),
)
],
),
);
}
}
以下是解析思考 燒腦的實(shí)踐
Flutter 應(yīng)用程序被 Android iOS平臺加載,在原生 Android 中,使用TextView就可輕松實(shí)現(xiàn)解析(如下代碼清單2-1),當(dāng)然在iOS中使用UILabel也可輕松實(shí)現(xiàn)(如下代碼清單2-2)。
// Android 原生 TextView加載Html的核心方法
//代碼清單2-1
// MxgsaTagHandler 定義的一個 TagHandler 用來處理點(diǎn)擊事件
lTextView.setText(Html.fromHtml(myContent, null, new MxgsaTagHandler(context)));
lTextView.setClickable(true);
lTextView.setMovementMethod(LinkMovementMethod.getInstance());
iOS UILable
// iOS 原生 UILabel加載Html的核心方法
//代碼清單2-2
//返回的HTML文本 如 <font color = 'red'></font>
NSString *str = @"htmlText";
NSString *HTMLString = [NSString stringWithFormat:@"<html><body>%@</body></html>", str ];
NSDictionary *options = @{NSDocumentTypeDocumentAttribute : NSHTMLTextDocumentType,
NSCharacterEncodingDocumentAttribute : @(NSUTF8StringEncoding)
};
NSData *data = [HTMLString dataUsingEncoding:NSUTF8StringEncoding];
NSMutableAttributedString * attributedString = [[NSMutableAttributedString alloc] initWithData:data options:options documentAttributes:nil error:nil];
NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init]; // 調(diào)整行間距
paragraphStyle.lineSpacing = 8.0;
paragraphStyle.alignment = NSTextAlignmentJustified;
[attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:NSMakeRange(0, attributedString.length)];
[attributedString addAttribute:NSFontAttributeName value:[UIFont systemFontOfSize:15] range:NSMakeRange(0, attributedString.length)];
_uiLabel.backgroundColor = [UIColor cyanColor];
_uiLabel.numberOfLines = 0;
_uiLabel.attributedText = attributedString;
[_uiLabel sizeToFit];
然后對于 Flutter 來講是可以順利的加載原生 View的 ,如下代碼清單 2-3所示就是在Flutter中通過 AndroidView 與 UiKitView來實(shí)現(xiàn)。
//Flutter中加載原生View核心方法
//代碼清單2-3
buildAndroidView() {
return AndroidView(
//設(shè)置標(biāo)識
viewType: "com.studyon./text_html",
//參數(shù)的編碼方式
creationParamsCodec: const StandardMessageCodec(),
);
}
/// 通過 UiKitView 來加載 iOS原生View
buildUIKitView() {
return UiKitView(
//標(biāo)識
viewType: "com.studyon./text_html",
//參數(shù)的編碼方式
creationParamsCodec: const StandardMessageCodec(),
);
}
于是小編開發(fā)了第一波操作,開發(fā)了這樣的一個插件來調(diào)用原生 View 實(shí)現(xiàn)渲染富文本標(biāo)簽,這個插件使用方式很簡單,如下所示:
HTMLTextWidet(
htmlText: "測試一下",
)
這一步操作真是所謂的騷操作,其實(shí)小編在開發(fā)前就覺得不太合適,不過以小編的個性,非得嘗試驗(yàn)證一下,現(xiàn)結(jié)果出來了,就是在加載時,由于應(yīng)用在列表中,使用 HTMLTextWidet 會有短暫的黑屏效果,而且內(nèi)存出吃不消,如下圖所示:
為什么會黑屏,閑魚技術(shù)團(tuán)隊(duì)有過論述在《Flutter中嵌入Native組件的正確姿勢》 以及 文章 《深入了解Flutter界面開發(fā)中有詳細(xì)論述》 。
所以結(jié)果是 :不可行。
用 Java 的思想來解析 String 的方式來處理 HTML 字符串,處理成小片段,然后使用Text結(jié)合 流式布局 Wrap 來組合,核心代碼如下清單 3-1 所示為解析:
/*
解析標(biāo)簽
*/
List<TagColorModel> findBackGroundColor(String htmlStr) {
List<TagColorModel> tagColorModelList = [];
List<String> colorSpiltList = [];
String driverAdvertisement = htmlStr;
if (driverAdvertisement != null) {
colorSpiltList = driverAdvertisement.split("background-color");
for (var i = 0; i < colorSpiltList.length; i++) {
TagColorModel itemColorModel = TagColorModel();
String colorsStr = colorSpiltList[i];
List<String> itemSpiltList = colorsStr.split(":#");
for (var j = 0; j < itemSpiltList.length; ++j) {
String item = itemSpiltList[j];
String itemColor = "";
String itemText = "";
try {
if (item.length >= 6) {
itemColor = item.toString().substring(0, 6);
if (itemColor.trim().toUpperCase() == "FFFFFF") {
itemColorModel.backGroundColor = ColorUtils.getRandomColor();
} else {
itemColorModel.backGroundColor = new Color(
int.parse(itemColor.trim(), radix: 16) + 0xFF000000);
}
int startIndex = item.indexOf("\">");
int endIndex = item.indexOf("</");
if (startIndex != -1 && endIndex >= startIndex) {
LogUtil.e("startIndex $startIndex endIndex $endIndex ");
itemText = item.substring(startIndex + 2, endIndex);
LogUtil.e("itemColor $itemColor itemText $itemText ");
itemColorModel.text = itemText;
tagColorModelList.add(itemColorModel);
}
}
} catch (e) {
///解析異常的 不必處理
}
}
}
}
LogUtil.e("${tagColorModelList.length} \n\n ");
return tagColorModelList;
}
然后 TagColorModel 的定義如下代碼清單 3-2所示:
///代碼清單 3-2
class TagColorModel {
///背景
Color backGroundColor;
///文本顏色
Color textColor;
///文本
String text;
TagColorModel(
{this.text = "",
this.backGroundColor = Colors.transparent,
this.textColor = Colors.white});
}
然后就是使用 Wrap 來使用解析的內(nèi)容,如下代碼清單3-3所示:
///代碼清單 3-3
///獲取背景顏色
List<TagColorModel> colorList = findBackGroundColor(htmlStr);
List<Widget> tagList = [];
for (var i = 0; i < colorList.length; ++i) {
TagColorModel model = colorList[i];
tagList.add(Container(
margin: EdgeInsets.only(right: 2, left: 4, top: 4),
padding: EdgeInsets.only(left: 6, right: 6),
decoration: BoxDecoration(
color: model.backGroundColor,
borderRadius: BorderRadius.all(Radius.circular(2)),
),
child: Text(
"${model.text}",
style: TextStyle(fontSize: 12, color: model.textColor),
),
));
}
///然后再使用 Wrap 包裹
Wrap(
alignment: WrapAlignment.spaceBetween,
children: tagList,
),
實(shí)踐結(jié)果:可行,但是有兼容性差,效率低。
當(dāng)然閑魚團(tuán)隊(duì)在文章《如何低成本實(shí)現(xiàn)Flutter富文本,看這一篇就夠了!》 中也有詳細(xì)論述過,與上述的思路差不多。
當(dāng)在Flutter中 Dart 從網(wǎng)站中提取數(shù)據(jù)時,html依賴庫是一個不錯的選擇,html 是一個開源的 Dart 包,主要用于從 HTML 中提取數(shù)據(jù),從中獲取節(jié)點(diǎn)的屬性、文本和 HTML以及各種節(jié)點(diǎn)的內(nèi)容。
dependencies:
html: ^0.14.0+3
于是乎小編也開始嘗試,首先是使用 Html 庫解析 HTML文本塊,將解析的 Document 通過遞歸方式遍歷出來所有的 node 節(jié)點(diǎn),如下代碼清單4-1所示:
代碼清單4-1
import 'package:html/parser.dart' as parser;
import 'package:html/dom.dart' as dom;
List<Widget> parse(String originHtmlString) {
// 空格替換 去除所有 br 標(biāo)簽用 \n 代替,
originHtmlString = originHtmlString.replaceAll('<br/>', '\n');
originHtmlString = originHtmlString.replaceAll('<br>', '\n');
originHtmlString = originHtmlString.replaceAll('<br />', '\n');
///html 依賴庫解析
dom.Document document = parser.parse(originHtmlString);
///獲取 DOM 中的 node 節(jié)點(diǎn)
dom.Node cloneNode = document.body.clone(true);
// 注意: 先序遍歷找到所有關(guān)鍵節(jié)點(diǎn)(由于是引用傳值,所以需要重新獲取一遍 hashCode)
List<dom.Node> keyNodeList = new List<dom.Node>();
int nodeIndex = 0;
///遞歸遍歷
parseNodesTree(cloneNode, callBack: (dom.Node childNode) {
if (childNode is dom.Element &&
truncateTagList.indexOf(childNode.localName) != -1) {
print('TEST: truncate tag nodeIndex = ${nodeIndex++}');
keyNodeList.add(childNode);
// 注意: 對于占據(jù)整行的圖片也作為關(guān)鍵節(jié)點(diǎn)處理
} else if (childNode is dom.Element &&
childNode.localName == 'img' &&
checkImageNeedNewLine(childNode)) {
print('TEST: one line image nodeIndex = ${nodeIndex++}');
keyNodeList.add(childNode);
}
});
}
///遞歸遍歷
void parseNodesTree(dom.Node node,
{NodeTreeCallBack callBack = printNodeName}) {
///遍歷 Node 節(jié)點(diǎn)
for (var i = 0; i < node.nodes.length; ++i) {
dom.Node item = node.nodes[i];
callBack(item);
parseNodesTree(item, callBack: callBack);
}
}
然后就是將 得出的 node 節(jié)點(diǎn) 與 Flutter 組件映射,文本使用 TextSpan ,圖片使用 Image ,然后將 樣式使用 TextStyle 映射,然后最后將解析的結(jié)果組件使用 Wrap 來包裹,就達(dá)到了現(xiàn)在的插件 flutter_html_rich_text 。
綜合實(shí)現(xiàn)思路就是 使用 HTML 庫完善了【燒腦思考實(shí)踐二】中的解析。
解析篇幅較長,大家有興趣可以看下 github 源碼。
完畢
單標(biāo)簽
網(wǎng)頁(程序)如果要和用戶產(chǎn)生互動,則必須借助一定的中介,這個中介一般是:文本輸入框、按鈕、多選框、單選框。而表單則是這些中介和放置這些中介的空間(<form action=”” methon=””></form>)。
在網(wǎng)頁中,這些文本輸入框、按鈕等等必須放置在由<form></form>這個標(biāo)簽所定義的空間中,否則沒有實(shí)際意義。所以,由<form></form>標(biāo)簽所定義的空間就是表單存在的空間。
【各種輸入類型】
呈現(xiàn)結(jié)果
姓名:
原始碼
<form action=http://www.baidu.com/nameproject.aspmethon=”post”>
姓名:<input type="text" name="name" size="20">
</form>
它有下列可設(shè)定之屬性:
呈現(xiàn)結(jié)果
性別:男 女
原始碼
<form>
性別:
男 <input type="radio" name="sex" value="boy">
女 <input type="radio" name="sex" value="girl">
</form>
它有下列可設(shè)定之屬性:
呈現(xiàn)結(jié)果
喜好: 電影 看書
原始碼
<form>
喜好:
<input type="checkbox" name="sex" value="movie">電影
<input type="checkbox" name="sex" value="book">看書
</form>
它有下列可設(shè)定之屬性:
呈現(xiàn)結(jié)果
請輸入密碼:
原始碼
<form>
請輸入密碼:<input type="password" name="input">
</form>
它有下列可設(shè)定之屬性:
呈現(xiàn)結(jié)果
原始碼
<form>
<input type="submit" value="送出資料">
<input type="reset" value="重新填寫">
</form>
它有下列可設(shè)定之屬性:
呈現(xiàn)結(jié)果
請按下按鈕:
原始碼
<form>
請按下按鈕:<input type="button" name="ok" value="我同意">
</form>
它有下列可設(shè)定之屬性:
呈現(xiàn)結(jié)果
隱藏欄位:
原始碼
<form>
隱藏欄位:<input type="hidden" name="nosee" value="看不到">
</form>
它有下列可設(shè)定之屬性:
【大量文字輸入元件】
呈現(xiàn)結(jié)果
請輸入您的意見:
原始碼
<form>
請輸入您的意見:<br>
<textarea name="talk" cols="20" rows="3"></textarea>
</form>
它有下列可設(shè)定之屬性:
【下拉式選單】
呈現(xiàn)結(jié)果
您喜歡看書嗎?:
非常喜歡
還算喜歡
不太喜歡
非常討厭
原始碼
<form>
您喜歡看書嗎?:
<select name="like">
<option value="非常喜歡">非常喜歡
<option value="還算喜歡">還算喜歡
<option value="不太喜歡">不太喜歡
<option value="非常討厭">非常討厭
</select>
</form>
它有下列可設(shè)定之屬性:
multiple,是設(shè)定此一欄位為復(fù)選,可以一次選好幾個選項(xiàng)。
....................................................................
我的微信公眾號:UI嚴(yán)選 —越努力,越幸運(yùn)
*請認(rèn)真填寫需求信息,我們會在24小時內(nèi)與您取得聯(lián)系。