在本系列的第二部分,我们通过将Pyodide和Python代码的执行卸载到一个Web Worker来改善用户体验。我们还用Pandas创建了一个建议和事实列表。
在最后一部分中,我们将研究如何将Python代码打包,使其更可读和可维护,添加一个搜索栏和一个删除按钮,并使用PouchDB添加一个持久的数据层。
--
Python单页应用程序系列。
- 第一部分。学习Pyodide的基础知识并创建基础应用程序
- 第二部分:用Pandas分析和处理数据,并使用Web Worker来加快应用速度
- 第三部分(本教程!)。创建一个Python包,添加额外的功能,并添加一个持久的数据层
目标
在本教程结束时,你应该能够。
- 将你的Python代码打包并导入Pyodide中
- 用PouchDB为你的应用程序添加一个数据层
我们正在构建的东西
首先,我们将把Python代码打包成一个单独的Python模块。这将使我们的代码更少的污染和更可读。我们将修改网络工作者以导入Pyodide中的Python文件。我们还将添加一个搜索栏和一个删除按钮。我们的最后一项任务将是添加一个持久的数据层,将数据存储在PouchDB数据库中,这将使我们的应用程序在页面重新加载时更快。
你可以在这里找到该应用程序的实时演示。
提高代码的可维护性
到目前为止,我们已经将Python代码直接添加到runPythonAsync 方法中。正如你可能已经知道的,这对小的代码片段来说是很好的,但随着代码的增长,它变得越来越难以维护和扩展。
为了改善情况,我们将
- 将Python代码分离成一个模块。
- 在worker.js中获取Python模块代码,将结果写入浏览器的虚拟内存中,并导入软件包以在Pyodide中使用它。
包装 Python 代码
在项目的根部创建一个名为main.py的新文件。
import io import pandas as pd def analyze_titles(titlesCSV): # 1. create csv buffer to make it readable by pandas csv_buffer = io.StringIO(titlesCSV) # 2. load the csv file all_titles = pd.read_csv(csv_buffer) # 3. sanitize the data # drop unnecessary columns all_titles = all_titles.drop( columns=[ "age_certification", "seasons", "imdb_id", ] ) # drop rows with null values for important columns sanitized_titles = all_titles.dropna( subset=[ "id", "title", "release_year", "genres", "production_countries", "imdb_score", "imdb_votes", "tmdb_score", "tmdb_popularity", ] ) # Convert the DataFrame to a JSON object. ('orient="records"' returns a list of objects) titles_list = sanitized_titles.head(10).to_json(orient="records") # 4. Create recommendation list for Shows and Movies # 4.1 Copy the sanitized_titles to add new column to it recommended_titles = sanitized_titles.copy() # 4.2 Add new column to the sanitized_titles recommended_titles["recommendation_score"] = ( sanitized_titles["imdb_votes"] * 0.3 + sanitized_titles["imdb_score"] * 0.3 + sanitized_titles["tmdb_score"] * 0.2 + sanitized_titles["tmdb_popularity"] * 0.2 ) # 4.3 Create Recommended movies list recommended_movies = ( recommended_titles.loc[recommended_titles["type"] == "MOVIE"] .sort_values(by="recommendation_score", ascending=False) .head(5) .to_json(orient="records") ) # 4.4 Create Recommended shows list recommended_shows = ( recommended_titles.loc[recommended_titles["type"] == "SHOW"] .sort_values(by="recommendation_score", ascending=False) .head(5) .to_json(orient="records") ) recommendations = {"movies": recommended_movies, "shows": recommended_shows} # 5. Create facts list for Movies and Shows facts_movies = ( sanitized_titles.loc[sanitized_titles["type"] == "MOVIE"] .groupby("release_year") .count()["id"] .sort_values(ascending=False) .head(1) .to_json(orient="table") ) facts_shows = ( sanitized_titles.loc[sanitized_titles["type"] == "SHOW"] .groupby("release_year") .count()["id"] .sort_values(ascending=False) .head(1) .to_json(orient="table") ) facts = {"movies": facts_movies, "shows": facts_shows} return titles_list, recommendations, facts
这里其实没有什么新东西。请自行查阅。
将Python文件传递给Pyodide
接下来,我们需要对*worker.*js 做一些修改。
// load pyodide.js importScripts("https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"); // Initialize pyodide and load Pandas async function initialize() { self.pyodide = await loadPyodide(); await self.pyodide.loadPackage("pandas"); } let initialized = initialize(); self.onmessage = async function (e) { await initialized; response = await fetch( "https://raw.githubusercontent.com/amirtds/kaggle-netflix-tv-shows-and-movies/main/titles.csv" ); response.ok && response.status === 200 ? (titles = await response.text()) : (titles = ""); // fetch main.py, save it in browser memory await self.pyodide.runPythonAsync(` from pyodide.http import pyfetch response = await pyfetch("main.py") with open("main.py", "wb") as f: f.write(await response.bytes()) `) // Importing fetched py module pkg = pyodide.pyimport("main"); // Run the analyze_titles function from main.py and assign the result to a variable let analyzedTitles = pkg.analyze_titles(titles); // convert the Proxy object to Javascript object analyzedTitles = analyzedTitles.toJs({ dict_converter: Object.fromEntries, }); // Set variables to corresponding values from the analyzedTitles object let titlesList = analyzedTitles[0]; let recommendedMovies = analyzedTitles[1].movies let recommendedShows = analyzedTitles[1].shows let factsMovies = analyzedTitles[2].movies let factsShows = analyzedTitles[2].shows self.postMessage({ titles: titlesList, recommendedMovies: recommendedMovies, recommendedShows: recommendedShows, factsMovies: factsMovies, factsShows: factsShows, }); };
请注意runPythonAsync 方法中的 Python 代码。我们使用pyfetch来获取本地的main.py文件,并将其保存在浏览器的虚拟内存中以便以后使用。
在你的终端中运行Python的http服务器。
然后,在浏览器中导航到http://localhost:8000/。
你应该看到和以前一样的结果,但现在Python代码的执行被打包在一个单独的模块中,使代码更易读和维护。另外,你现在可以在你的代码编辑器中利用语法高亮的优势,并为其编写自动测试。
SPA功能
随着应用程序性能的提高和代码被分离成一个模块,让我们把注意力转回到功能开发上。
在这一节中,我们将添加一个删除按钮和搜索功能,以根据它们的名字过滤标题。
删除按钮
为了增加删除的功能,我们需要。
- 在每个标题旁边的DOM上添加一个按钮
- 设置一个事件监听器,在点击按钮时触发删除功能
- 创建一个函数来处理实际的删除操作
首先,在index.html的表头添加一个新的列。
<thead class="bg-gray-50"> <tr> <th scope="col" class="whitespace-nowrap py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Title</th> <th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th> <th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Release Year</th> <th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Genre</th> <th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900">Production Country</th> <!-- NEW --> <th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900"></th> </tr> </thead>
接下来,对表体做如下修改。
<tbody class="divide-y divide-gray-200 bg-white"> ${this.state.titles.length > 0 ? JSON.parse(this.state.titles).map(function (title) { return (` <tr id=${title.id}> <td class="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-gray-500 sm:pl-6">${title.title}</td> <td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-gray-900">${title.type}</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">${title.release_year}</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">${title.genres}</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">${title.production_countries}</td> <!-- NEW --> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500"> <button id=${title.id} class="delete text-red-600 hover:text-red-900">Delete</button> </td> </tr> `) }).join('') : (` <tr> <td class="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-gray-500 sm:pl-6">Titles are loading...</td> <td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-gray-900">Titles are loading...</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td> <!-- NEW --> <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500">Titles are loading...</td> </tr> `) } </tbody>
在这里,我们添加了删除按钮,以及当标题仍在加载时的额外加载信息。
紧接着,为index.html中的App 类添加两个新方法。
setupEvents() { let deleteButtons = document.querySelectorAll(".delete") .forEach((button) => { button.addEventListener("click", () => this.deleteTitle(button.id)) }) } deleteTitle(id) { this.state.titles = JSON.stringify(JSON.parse(this.state.titles).filter(title => title.id != id)); this.render() }
注释。
setupEvents是用来给删除按钮添加事件监听器的,这样当用户点击按钮时, 方法就会被调用。deleteTitledeleteTitle然后用于从列表中删除一个标题。
最后更新render ,调用setupEvents 。
render() { app.innerHTML = this.view(); this.setupEvents(); // NEW }
测试一下这个。
想找一个挑战吗?试着在一个标题被删除后更新建议和事实。
搜索框
接下来,让我们添加一个搜索框,按标题过滤电影和节目列表。
在index.html的开头body 标签下面添加搜索框的HTML。
<!-- Start Search box --> <div class="absolute top-1 right-0 mr-20 z-50"> <div class="h-full max-w-7xl mt-16 px-4 sm:px-6 lg:px-8 flex flex-row-reverse"> <label for="search" class="sr-only">Search</label> <div class="relative text-gray-400 focus-within:text-gray-600"> <div class="pointer-events-none absolute inset-y-0 left-0 pl-3 flex items-center"> <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" /> </svg> </div> <input id="search" class="block w-full bg-white py-2 pl-10 pr-3 border border-transparent rounded-md leading-5 text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-indigo-600 focus:ring-white focus:border-white sm:text-sm" placeholder="Search" type="search" name="search" autofocus> </div> </div> </div> <!-- End Search box -->
接下来,给App's state添加一个新值。
state = { titles: [], recommendedMovies: [], recommendedShows: [], factsMovies: [], factsShows: [], filteredTitles: [], // NEW }
在view 方法中,我们需要循环浏览titles 状态,而不是循环浏览filteredTitles 状态。
所以,改变。
${this.state.titles.length > 0 ? JSON.parse(this.state.titles).map(function (title) {
到。
${this.state.filteredTitles.length > 0 ? JSON.parse(this.state.filteredTitles).map(function (title) {
更新deleteTitle 方法,使用filteredTitles 状态而不是titles 状态。
deleteTitle(id) { this.state.filteredTitles = JSON.stringify( JSON.parse(this.state.filteredTitles).filter(function (title) { return title.id !== id; }) ); this.render(); }
添加一个名为searchTitle 的新方法来处理搜索逻辑。
searchTitle(name) { this.state.filteredTitles = JSON.stringify( JSON.parse(this.state.titles).filter((title) => title.title.toLowerCase().includes(name.toLowerCase()) ) ); this.render(); }
为搜索框添加一个新的事件监听器setupEvents ,当用户在搜索框中输入时调用searchTitle 。
setupEvents() { let deleteButtons = document.querySelectorAll(".delete").forEach((button) => { button.addEventListener("click", () => this.deleteTitle(button.id)); }); let searchBox = document .querySelector("#search") .addEventListener("keyup", (e) => { this.searchTitle(e.target.value); }); }
最后,将filteredTitles 状态设置为titles 状态,这样它就有一些初始值可以在worker.onmessage 中使用。
worker.onmessage = function (event) { event.data.titles !== undefined ? appComponent.state.titles = event.data.titles : []; event.data.recommendedMovies !== undefined ? appComponent.state.recommendedMovies = event.data.recommendedMovies : []; event.data.recommendedShows !== undefined ? appComponent.state.recommendedShows = event.data.recommendedShows : []; event.data.factsMovies !== undefined ? appComponent.state.factsMovies = event.data.factsMovies : []; event.data.factsShows !== undefined ? appComponent.state.factsShows = event.data.factsShows : []; // NEW event.data.titles !== undefined ? appComponent.state.filteredTitles = event.data.titles : []; appComponent.render() }
在浏览器中测试一下吧!
持久性数据层
我们将添加的最后一个功能是一个持久化的日期层来保存数据,这样我们就不必在每次页面重新加载时远程获取CSV文件并分析和处理数据了。当标题被删除时,我们也会保存结果。
对于持久性,我们将使用PouchDB。
PouchDB
PouchDB是一个为浏览器设计的开源数据库,允许你在浏览器中本地保存数据。由于所有的数据都存储在IndexedDB中,它给我们的应用程序带来了离线支持。它被所有现代浏览器所支持。
设置
更新index.html文件的头部,就在Tailwind CSS之后,以安装:。
<head> <script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script> <script src="https://cdn.tailwindcss.com"></script> <!-- NEW --> <script src="https://cdn.jsdelivr.net/npm/[[email protected]](https://testdriven.io/cdn-cgi/l/email-protection)/dist/pouchdb.min.js"></script> </head>
现在,我们可以为标题、推荐和事实创建不同的数据库。
<head> <script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script> <script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.jsdelivr.net/npm/[[email protected]](https://testdriven.io/cdn-cgi/l/email-protection)/dist/pouchdb.min.js"></script> <!-- NEW --> <script> // Setup PouchDB databases var titlesDB = new PouchDB('titles'); var recommendedMoviesDB = new PouchDB('recommendedMovies'); var recommendedShowsDB = new PouchDB('recommendedShows'); var factsMoviesDB = new PouchDB('factsMovies'); var factsShowsDB = new PouchDB('factsShows'); var remoteCouch = false; </script> </head>
保存数据
为了防止在每次页面加载时运行获取和处理数据的繁重操作,我们可以简单地在初始页面加载时运行worker.js文件,将结果保存到PouchDB数据库中,然后在后续页面加载时从数据库中获取数据。
在index.html中,像这样更新最后的脚本标签。
<script> titlesDB.info().then(function (info) { if (info.doc_count == 0) { const worker = new Worker("worker.js"); worker.postMessage("Running Pyodide"); worker.onmessage = function (event) { event.data.titles !== undefined ? (appComponent.state.titles = event.data.titles) : []; event.data.titles !== undefined ? (appComponent.state.filteredTitles = event.data.titles) : []; event.data.recommendedMovies !== undefined ? (appComponent.state.recommendedMovies = event.data.recommendedMovies) : []; event.data.recommendedShows !== undefined ? (appComponent.state.recommendedShows = event.data.recommendedShows) : []; event.data.factsMovies !== undefined ? (appComponent.state.factsMovies = event.data.factsMovies) : []; event.data.factsShows !== undefined ? (appComponent.state.factsShows = event.data.factsShows) : []; appComponent.render(); // Add titles to database appComponent.state.titles.length > 0 ? titlesDB .bulkDocs(JSON.parse(appComponent.state.titles)) .then(function (result) { // handle result }) .catch(function (err) { console.log("titles DB:", err); }) : console.log("No titles to add to database"); // Add recommended movies to database appComponent.state.recommendedMovies.length > 0 ? recommendedMoviesDB .bulkDocs(JSON.parse(appComponent.state.recommendedMovies)) .then(function (result) { // handle result }) .catch(function (err) { console.log("recommendedMovies DB:", err); }) : console.log("No recommended movies to add to database"); // Add recommended shows to database appComponent.state.recommendedShows.length > 0 ? recommendedShowsDB .bulkDocs(JSON.parse(appComponent.state.recommendedShows)) .then(function (result) { // handle result }) .catch(function (err) { console.log("recommendedShows DB:", err); }) : console.log("No recommended shows to add to database"); // Add facts movies to database appComponent.state.factsMovies.length > 0 ? factsMoviesDB .bulkDocs(JSON.parse(appComponent.state.factsMovies).data) .then(function (result) { // handle result }) .catch(function (err) { console.log("factsMovies DB:", err); }) : console.log("No facts movies to add to database"); // Add facts shows to database appComponent.state.factsShows.length > 0 ? factsShowsDB .bulkDocs(JSON.parse(appComponent.state.factsShows).data) .then(function (result) { // handle result }) .catch(function (err) { console.log("factsShows DB:", err); }) : console.log("No facts shows to add to database"); }; } else { console.log("Database already populated"); } }); </script>
这里,我们检查了标题数据库是否为空。
- 如果是,我们就获取并保存数据
- 如果不是,我们只是在控制台记录了一个 "数据库已填充 "的信息
继续在浏览器中测试这个。
在你的浏览器的开发者工具中,在 "应用程序 "标签的 "IndexedDB "下,你应该看到以下数据库。
_pouch_titles_pouch_factsMovies_pouch_factsShows_pouch_recommendedMovies_pouch_recommendedShows
重新加载该页面。会发生什么?什么都没有,除了孤独的控制台日志,对吗?我们仍然需要从本地数据库加载数据。要做到这一点,请用else 块更新。
// use database to populate app state // setting titles state titlesDB.allDocs({ include_docs: true, descending: true }, function(err, doc) { const titles = doc.rows.map(function(row) { return row.doc }) appComponent.state.titles = JSON.stringify(titles); appComponent.state.filteredTitles = JSON.stringify(titles); appComponent.render() }); // setting recommended movies state recommendedMoviesDB.allDocs({ include_docs: true, descending: true }, function(err, doc) { const recommendedMovies = doc.rows.map(function(row) { return row.doc }) appComponent.state.recommendedMovies = JSON.stringify(recommendedMovies); appComponent.render() }); // setting recommended shows state recommendedShowsDB.allDocs({ include_docs: true, descending: true }, function(err, doc) { const recommendedShows = doc.rows.map(function(row) { return row.doc }) appComponent.state.recommendedShows = JSON.stringify(recommendedShows); appComponent.render() }); // setting facts movies state factsMoviesDB.allDocs({ include_docs: true, descending: true }, function(err, doc) { const factsMovies = doc.rows.map(function(row) { return row.doc }) appComponent.state.factsMovies = JSON.stringify({ data: factsMovies }); appComponent.render() }); // setting facts shows state factsShowsDB.allDocs({ include_docs: true, descending: true }, function(err, doc) { const factsShows = doc.rows.map(function(row) { return row.doc }) appComponent.state.factsShows = JSON.stringify({ data: factsShows }); appComponent.render() });
回到你的浏览器中,让我们从新测试一下。删除 "应用程序 "标签中 "IndexedDB "部分的所有数据库。重新加载该页面。应该需要几秒钟的时间来填充数据。随后的重新加载应该是即时加载。
处理删除
为了在用户删除一个标题时正确地删除标题,请像这样更新deleteTitle 。
deleteTitle(id) { const title = JSON.parse(this.state.titles).find((title) => title.id == id); titlesDB.remove(title); this.state.titles = JSON.stringify( JSON.parse(this.state.titles).filter(function (title) { return title.id !== id; }) ); this.state.filteredTitles = JSON.stringify( JSON.parse(this.state.filteredTitles).filter(function (title) { return title.id !== id; }) ); this.render(); }
回到你的浏览器中,测试一下删除的情况。通过重新加载页面,确保它持续存在。
结论
这就是了!
在这个系列教程中,你学会了如何用Python构建一个单页应用程序。用Pyodide在浏览器中执行Python,打开了许多以前不可能实现的新大门。例如,你。
- 可以在浏览器中使用强大的库,如 Pandas
- 可以用Python直接访问所有的Web APIs
- 甚至可以使用Python来操作DOM
而且,您可以将 Python 与 JavaScript 一起使用,以最大限度地发挥这两种语言(以及它们的大量库)所能提供的好处
我们还看到了如何通过使用网络工作者在后台运行繁重的计算,与主线程分开来提高我们应用程序的性能。通过将Python脚本移到一个单独的模块中,我们使我们的应用程序减少了污染,它更容易维护和扩展。
我们通过使用PouchDB为我们的应用程序添加一个数据层来完成这个系列。这个功能通过减少页面加载和引入离线功能改善了用户体验。
你可以在这里找到本教程的源代码。
我希望你喜欢这个系列。如果你有任何问题或意见,请随时联系我。干杯!
--
Python单页应用程序系列。